diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index 08d4767..ee966cf 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -28,5 +28,6 @@ api_platform: - '%kernel.project_dir%/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto' - '%kernel.project_dir%/src/Domain/Manga/Infrastructure/ApiPlatform/Resource' - '%kernel.project_dir%/src/Domain/Reader/Infrastructure/ApiPlatform/Resource' + - '%kernel.project_dir%/src/Domain/Shared/Infrastructure/ApiPlatform/Resource' patch_formats: json: ['application/merge-patch+json'] diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index a735038..5001e01 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -1,12 +1,15 @@ doctrine: dbal: - url: '%env(resolve:DATABASE_URL)%' + connections: + default: + url: '%env(resolve:DATABASE_URL)%' + use_savepoints: true + profiling_collect_backtrace: '%kernel.debug%' # IMPORTANT: You MUST configure your server version, # either here or in the DATABASE_URL env var (see .env file) #server_version: '16' - profiling_collect_backtrace: '%kernel.debug%' orm: auto_generate_proxy_classes: true enable_lazy_ghost_objects: true @@ -21,13 +24,11 @@ doctrine: dir: '%kernel.project_dir%/src/Entity' prefix: 'App\Entity' alias: App - # Ajout du mapping pour le domaine Scraping - Scraping: - type: attribute + Shared: is_bundle: false - dir: '%kernel.project_dir%/src/Domain/Scraping/Infrastructure/Persistence/Entity' - prefix: 'App\Domain\Scraping\Infrastructure\Persistence\Entity' - alias: Scraping + dir: '%kernel.project_dir%/src/Domain/Shared/Infrastructure/Persistence/Entity' + prefix: 'App\Domain\Shared\Infrastructure\Persistence\Entity' + alias: Shared when@test: doctrine: diff --git a/migrations/Version20250205231923.php b/migrations/Version20250205231923.php deleted file mode 100644 index 8c996a5..0000000 --- a/migrations/Version20250205231923.php +++ /dev/null @@ -1,34 +0,0 @@ -addSql('CREATE TABLE scraping_jobs (id VARCHAR(36) NOT NULL, chapter_number VARCHAR(255) NOT NULL, manga_id VARCHAR(255) NOT NULL, source_id VARCHAR(255) NOT NULL, pages JSON NOT NULL, status VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, completed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); - $this->addSql('COMMENT ON COLUMN scraping_jobs.created_at IS \'(DC2Type:datetime_immutable)\''); - $this->addSql('COMMENT ON COLUMN scraping_jobs.completed_at IS \'(DC2Type:datetime_immutable)\''); - } - - public function down(Schema $schema): void - { - // this down() migration is auto-generated, please modify it to your needs - $this->addSql('CREATE SCHEMA public'); - $this->addSql('DROP TABLE scraping_jobs'); - } -} diff --git a/migrations/Version20250210154832.php b/migrations/Version20250210154832.php deleted file mode 100644 index 23977cd..0000000 --- a/migrations/Version20250210154832.php +++ /dev/null @@ -1,34 +0,0 @@ -addSql('ALTER TABLE scraping_jobs ADD cbz_path VARCHAR(255) DEFAULT NULL'); - $this->addSql('ALTER TABLE scraping_jobs ADD failure_reason VARCHAR(255) DEFAULT NULL'); - } - - public function down(Schema $schema): void - { - // this down() migration is auto-generated, please modify it to your needs - $this->addSql('CREATE SCHEMA public'); - $this->addSql('ALTER TABLE scraping_jobs DROP cbz_path'); - $this->addSql('ALTER TABLE scraping_jobs DROP failure_reason'); - } -} diff --git a/migrations/Version20250328205205.php b/migrations/Version20250328205205.php new file mode 100644 index 0000000..9862643 --- /dev/null +++ b/migrations/Version20250328205205.php @@ -0,0 +1,38 @@ +addSql('CREATE TABLE failed_job (id VARCHAR(255) NOT NULL, type VARCHAR(255) NOT NULL, failure_reason TEXT NOT NULL, failed_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, context JSON NOT NULL, PRIMARY KEY(id))'); + $this->addSql('COMMENT ON COLUMN failed_job.failed_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('CREATE TABLE job (id VARCHAR(255) NOT NULL, type VARCHAR(255) NOT NULL, status VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, started_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, completed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, failure_reason TEXT DEFAULT NULL, attempts INT NOT NULL, max_attempts INT NOT NULL, context JSON NOT NULL, PRIMARY KEY(id))'); + $this->addSql('COMMENT ON COLUMN job.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN job.started_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN job.completed_at IS \'(DC2Type:datetime_immutable)\''); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('DROP TABLE failed_job'); + $this->addSql('DROP TABLE job'); + } +} diff --git a/src/Domain/Manga/Application/QueryHandler/SearchMangaHandler.php b/src/Domain/Manga/Application/QueryHandler/SearchMangaHandler.php index 060a1ff..35e49ba 100644 --- a/src/Domain/Manga/Application/QueryHandler/SearchMangaHandler.php +++ b/src/Domain/Manga/Application/QueryHandler/SearchMangaHandler.php @@ -18,10 +18,11 @@ readonly class SearchMangaHandler { $mangaCollection = $this->mangaProvider->search($query->title); + return new MangaSearchResponse( array_map( - fn (Manga$manga) => new MangaSearchItem( - id: $manga->getId()->getValue(), + fn (Manga $manga, int $index) => new MangaSearchItem( + id: $index, externalId: $manga->getExternalId()->getValue(), title: $manga->getTitle()->getValue(), slug: $manga->getSlug()->getValue(), @@ -34,8 +35,9 @@ readonly class SearchMangaHandler thumbnailUrl: $manga->getImageUrls()?->getThumbnail(), rating: $manga->getRating() ), - $mangaCollection->getItems() + $mangaCollection->getItems(), + array_keys($mangaCollection->getItems()) ) ); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/CreateMangaResource.php b/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/CreateMangaResource.php index 21e0e08..0d8eb26 100644 --- a/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/CreateMangaResource.php +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/CreateMangaResource.php @@ -15,7 +15,23 @@ use Symfony\Component\Validator\Constraints as Assert; processor: CreateMangaProcessor::class, openapiContext: [ 'summary' => 'Create a new manga from Mangadex', - 'description' => 'Creates a new manga by fetching its data from Mangadex using an external ID' + 'description' => 'Creates a new manga by fetching its data from Mangadex using an external ID', + 'requestBody' => [ + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'required' => ['externalId'], + 'properties' => [ + 'externalId' => [ + 'type' => 'string', + 'description' => 'The Mangadex ID of the manga' + ] + ] + ] + ] + ] + ] ] ) ] @@ -24,4 +40,4 @@ class CreateMangaResource { #[Assert\NotBlank] public string $externalId; -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/SearchMangaStateProvider.php b/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/SearchMangaStateProvider.php index b4dc4ad..c0df5f6 100644 --- a/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/SearchMangaStateProvider.php +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/SearchMangaStateProvider.php @@ -34,10 +34,11 @@ readonly class SearchMangaStateProvider implements ProviderInterface genres: $item->genres, status: $item->status, imageUrl: $item->imageUrl, + thumbnailUrl: $item->thumbnailUrl, rating: $item->rating ), $response->items ) ); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/Domain/Manga/Infrastructure/Provider/MangadexProvider.php b/src/Domain/Manga/Infrastructure/Provider/MangadexProvider.php index 37a77a0..580def8 100644 --- a/src/Domain/Manga/Infrastructure/Provider/MangadexProvider.php +++ b/src/Domain/Manga/Infrastructure/Provider/MangadexProvider.php @@ -59,7 +59,7 @@ readonly class MangadexProvider implements MangaProviderInterface try { $attributes = $result['attributes']; $title = $attributes['title']['en'] ?? null; - + if (!$title) { return null; } @@ -85,17 +85,19 @@ readonly class MangadexProvider implements MangaProviderInterface } return new Manga( - new MangaId((string) Uuid::uuid4()), - new MangaTitle($title), - new MangaSlug($this->slugger->slug($title)->lower()), - $attributes['description']['fr'] ?? $attributes['description']['en'] ?? '', - $author, - $attributes['year'] ?? 0, - $genres, - $attributes['status'], - new ExternalId($result['id']), - $imageUrl, - null + id: new MangaId((string) Uuid::uuid4()), + title: new MangaTitle($title), + slug: new MangaSlug($this->slugger->slug($title)->lower()), + description: $attributes['description']['fr'] ?? $attributes['description']['en'] ?? '', + author: $author, + publicationYear: $attributes['year'] ?? 0, + genres: $genres, + status: $attributes['status'], + externalId: new ExternalId($result['id']), + imageUrl: $imageUrl, + rating: null, + imageUrls: null, + createdAt: new \DateTimeImmutable(), ); } catch (\Exception $e) { return null; @@ -128,13 +130,13 @@ readonly class MangadexProvider implements MangaProviderInterface { try { $result = $this->client->getManga($externalId->getValue()); - + if (!isset($result['data'])) { return null; } $manga = $this->createMangaFromResult($result['data']); - + if ($manga) { $this->enrichWithRatings([$manga]); } @@ -144,4 +146,4 @@ readonly class MangadexProvider implements MangaProviderInterface return null; } } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandler.php b/src/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandler.php index c2dcf8d..b9aeb44 100644 --- a/src/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandler.php +++ b/src/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandler.php @@ -17,8 +17,10 @@ use App\Domain\Scraping\Domain\Model\ScrapingJob; use App\Domain\Scraping\Domain\Model\ValueObject\CbzGenerationRequest; use App\Domain\Scraping\Domain\Model\ValueObject\ScrapingRequest; use App\Domain\Scraping\Domain\Model\ValueObject\TempDirectory; +use App\Domain\Shared\Domain\Contract\JobRepositoryInterface; use Ramsey\Uuid\Uuid; use Symfony\Component\Messenger\MessageBusInterface; +use Doctrine\ORM\EntityManagerInterface; readonly class ScrapeChapterHandler { @@ -26,84 +28,91 @@ readonly class ScrapeChapterHandler private ScraperInterface $scraper, private ImageDownloaderInterface $imageDownloader, private CbzGeneratorInterface $cbzGenerator, - private ScrapingJobRepositoryInterface $scrapingJobRepository, + private JobRepositoryInterface $jobRepository, private ChapterRepositoryInterface $chapterRepository, private MangaRepositoryInterface $mangaRepository, private SourceRepositoryInterface $sourceRepository, - private MessageBusInterface $eventBus + private MessageBusInterface $eventBus, + private EntityManagerInterface $entityManager ) { } public function handle(ScrapeChapter $command): void { + $job = null; try { - // 1. Création du job + // 1. Création du job dans sa propre transaction $job = new ScrapingJob( Uuid::uuid4()->toString(), $command->mangaId, $command->chapterNumber, $command->sourceId ); - $this->scrapingJobRepository->save($job); + $job->start(); + $this->jobRepository->save($job); - // 2. Préparation des données - $manga = $this->mangaRepository->getById($command->mangaId); - $chapter = $this->chapterRepository->getByMangaIdAndChapterNumber($command->mangaId, $command->chapterNumber); - $source = $this->sourceRepository->getById($command->sourceId); + // 2. Nouvelle transaction pour le reste des opérations + $this->entityManager->beginTransaction(); + try { + // Préparation des données + $manga = $this->mangaRepository->getById($command->mangaId); + $chapter = $this->chapterRepository->getByMangaIdAndChapterNumber($command->mangaId, $command->chapterNumber); + $source = $this->sourceRepository->getById($command->sourceId); - $this->eventBus->dispatch(new ChapterScrapingStarted($job->getId())); + throw new \Exception('test'); - // 3. Scraping des URLs - $scrapingRequest = new ScrapingRequest( - 'html', - $source->buildChapterUrl($manga->getSlug(), $command->chapterNumber), - $source->getScrappingParameters(), - $job->getId() - ); + // 3. Scraping des URLs + $scrapingRequest = new ScrapingRequest( + 'html', + $source->buildChapterUrl($manga->getSlug(), $command->chapterNumber), + $source->getScrappingParameters() + ); - $scrapingResult = $this->scraper->scrape($scrapingRequest); - $job->totalPages = $scrapingResult->getTotalPages(); - $this->scrapingJobRepository->save($job); + $scrapingResult = $this->scraper->scrape($scrapingRequest); - // 4. Téléchargement des images - $tempDir = new TempDirectory(); - $downloadResults = $this->imageDownloader->downloadBatch( - $scrapingResult->getImageUrls(), - $tempDir, - $job->getId() - ); + // 4. Téléchargement des images + $tempDir = new TempDirectory(); + $downloadResults = $this->imageDownloader->downloadBatch( + $scrapingResult->getImageUrls(), + $tempDir, + $job->id + ); - // 5. Génération du CBZ - $cbzRequest = new CbzGenerationRequest( - $manga->getTitle(), - $manga->getPublicationYear(), - $chapter->volumeNumber, - $command->chapterNumber, - $tempDir, - array_map(fn($r) => $r->getLocalPath(), $downloadResults) - ); + // 5. Génération du CBZ + $cbzRequest = new CbzGenerationRequest( + $manga->getTitle(), + $manga->getPublicationYear(), + $chapter->volumeNumber, + $command->chapterNumber, + $tempDir, + array_map(fn($r) => $r->getLocalPath(), $downloadResults) + ); - $cbzPath = $this->cbzGenerator->generate($cbzRequest); + $cbzPath = $this->cbzGenerator->generate($cbzRequest); - // 6. Mise à jour et sauvegarde - $job->complete(); - $job->cbzPath = $cbzPath; - $this->scrapingJobRepository->save($job); + // 6. Mise à jour et sauvegarde + $chapter->cbzPath = $cbzPath->getPath(); + $this->chapterRepository->save($chapter); - $chapter->cbzPath = $cbzPath->getPath(); - $this->chapterRepository->save($chapter); + $job->complete(); + $this->jobRepository->save($job); - $this->eventBus->dispatch(new ChapterScraped($job->getId())); + $this->entityManager->commit(); - // 7. Nettoyage - $tempDir->cleanup(); + $this->eventBus->dispatch(new ChapterScraped($job->id)); + + // 7. Nettoyage + $tempDir->cleanup(); + } catch (\Exception $e) { + $this->entityManager->rollback(); + throw $e; + } } catch (\Exception $e) { if (isset($job)) { $job->fail($e->getMessage()); - $this->scrapingJobRepository->save($job); + $this->jobRepository->save($job); } $this->eventBus->dispatch(new ChapterScrapingFailed($command->mangaId, $command->chapterNumber, $e->getMessage())); - throw $e; } } } diff --git a/src/Domain/Scraping/Domain/Contract/Repository/ScrapingJobRepositoryInterface.php b/src/Domain/Scraping/Domain/Contract/Repository/ScrapingJobRepositoryInterface.php deleted file mode 100644 index 395145f..0000000 --- a/src/Domain/Scraping/Domain/Contract/Repository/ScrapingJobRepositoryInterface.php +++ /dev/null @@ -1,12 +0,0 @@ -status = ScrapingStatus::PENDING; - $this->createdAt = new \DateTimeImmutable(); - } - - public function addPage(PageNumber $pageNumber, ImageUrl $imageUrl): void - { - $this->pages[$pageNumber->getValue()] = $imageUrl->getValue(); - if ($this->status === ScrapingStatus::PENDING) { - $this->status = ScrapingStatus::IN_PROGRESS; - } - } - - public function complete(): void - { - $this->status = ScrapingStatus::COMPLETED; - $this->completedAt = new \DateTimeImmutable(); - } - - public function fail(string $exceptionMessage): void - { - $this->failureReason = $exceptionMessage; - $this->status = ScrapingStatus::FAILED; - $this->completedAt = new \DateTimeImmutable(); - } - - public function getId(): string - { - return $this->id; - } - - public function getChapterNumber(): float - { - return $this->chapterNumber; - } - - public function getMangaId(): string - { - return $this->mangaId; - } - - public function getSourceId(): string - { - return $this->sourceId; - } - - public function setStatus(ScrapingStatus $status): void - { - $this->status = $status; + parent::__construct($id, 'scraping_job'); + $this->maxAttempts = 1; + $this->context = [ + 'mangaId' => $mangaId, + 'chapterNumber' => $chapterNumber, + 'sourceId' => $sourceId + ]; } } diff --git a/src/Domain/Scraping/Domain/Model/ScrapingStatus.php b/src/Domain/Scraping/Domain/Model/ScrapingStatus.php deleted file mode 100644 index eb82c20..0000000 --- a/src/Domain/Scraping/Domain/Model/ScrapingStatus.php +++ /dev/null @@ -1,11 +0,0 @@ -scrapingParameters; } - - public function getJobId(): string - { - return $this->jobId; - } } diff --git a/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto/ScrapingStatusResponse.php b/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto/ScrapingStatusResponse.php deleted file mode 100644 index e1ebf4d..0000000 --- a/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto/ScrapingStatusResponse.php +++ /dev/null @@ -1,42 +0,0 @@ - new Link( - fromProperty: 'jobId', - toProperty: 'id', - fromClass: ScrapingStatusResponse::class, - toClass: ScrapingJob::class - ) - ] - ), - ], -)] -readonly class ScrapingStatusResponse -{ - public function __construct( - #[ApiProperty(identifier: true)] - public string $jobId, - #[ApiProperty] - public string $status, - #[ApiProperty] - public ?float $progress = null, - #[ApiProperty] - public ?string $error = null - ) { - } -} diff --git a/src/Domain/Scraping/Infrastructure/ApiPlatform/State/Provider/ScrapingStatusStateProvider.php b/src/Domain/Scraping/Infrastructure/ApiPlatform/State/Provider/ScrapingStatusStateProvider.php deleted file mode 100644 index cfbe7e6..0000000 --- a/src/Domain/Scraping/Infrastructure/ApiPlatform/State/Provider/ScrapingStatusStateProvider.php +++ /dev/null @@ -1,37 +0,0 @@ -scrapingJobRepository->findById($uriVariables['jobId']); - - if (!$job) { - throw new NotFoundHttpException('Job de scraping non trouvé'); - } - - $progress = 0; - if ($job->totalPages > 0) { - $progress = (count($job->pages) / $job->totalPages) * 100; - } - - return new ScrapingStatusResponse( - jobId: $job->getId(), - status: $job->status->value, - progress: $progress - ); - } -} diff --git a/src/Domain/Scraping/Infrastructure/Persistence/DoctrineScrapingJobRepository.php b/src/Domain/Scraping/Infrastructure/Persistence/DoctrineScrapingJobRepository.php deleted file mode 100644 index b42a6f8..0000000 --- a/src/Domain/Scraping/Infrastructure/Persistence/DoctrineScrapingJobRepository.php +++ /dev/null @@ -1,76 +0,0 @@ -entityManager->getRepository(ScrapingJobEntity::class)->find($job->getId()); - - if ($existingEntity) { - $existingEntity->setStatus($job->status->value); - $existingEntity->setPages($job->pages); - $existingEntity->setCompletedAt($job->completedAt); - $existingEntity->setCbzPath($job->cbzPath?->getPath()); - $existingEntity->setFailureReason($job->failureReason); - } else { - $entity = ScrapingJobEntity::fromDomain($job); - $this->entityManager->persist($entity); - } - - $this->entityManager->flush(); - } - - public function findById(string $id): ?ScrapingJob - { - $entity = $this->entityManager->getRepository(ScrapingJobEntity::class) - ->find($id); - - return $entity?->toDomain(); - } - - public function findByChapterId(string $chapterId): ?ScrapingJob - { - $entity = $this->entityManager->getRepository(ScrapingJobEntity::class) - ->findOneBy(['chapterId' => $chapterId]); - - return $entity?->toDomain(); - } - - public function findPendingJobs(): array - { - $entities = $this->entityManager->getRepository(ScrapingJobEntity::class) - ->createQueryBuilder('sj') - ->where('sj.status = :status') - ->setParameter('status', ScrapingStatus::PENDING->value) - ->getQuery() - ->getResult(); - - return array_map(fn (ScrapingJobEntity $entity) => $entity->toDomain(), $entities); - } - - public function findInProgressJobs(): array - { - $entities = $this->entityManager->getRepository(ScrapingJobEntity::class) - ->createQueryBuilder('sj') - ->where('sj.status = :status') - ->setParameter('status', ScrapingStatus::IN_PROGRESS->value) - ->getQuery() - ->getResult(); - - return array_map(fn (ScrapingJobEntity $entity) => $entity->toDomain(), $entities); - } -} diff --git a/src/Domain/Scraping/Infrastructure/Persistence/Entity/ScrapingJobEntity.php b/src/Domain/Scraping/Infrastructure/Persistence/Entity/ScrapingJobEntity.php deleted file mode 100644 index 53fbc4e..0000000 --- a/src/Domain/Scraping/Infrastructure/Persistence/Entity/ScrapingJobEntity.php +++ /dev/null @@ -1,103 +0,0 @@ -id = $job->getId(); - $entity->chapterNumber = $job->getChapterNumber(); - $entity->mangaId = $job->getMangaId(); - $entity->sourceId = $job->getSourceId(); - $entity->pages = $job->pages; - $entity->status = $job->status->value; - $entity->createdAt = $job->createdAt; - $entity->completedAt = $job->completedAt; - $entity->cbzPath = $job->cbzPath?->getPath(); - $entity->failureReason = $job->failureReason; - return $entity; - } - - public function toDomain(): ScrapingJob - { - $job = new ScrapingJob( - id: $this->id, - mangaId: $this->mangaId, - chapterNumber: $this->chapterNumber, - sourceId: $this->sourceId - ); - - $job->status = ScrapingStatus::from($this->status); - $job->pages = $this->pages; - $job->createdAt = $this->createdAt; - $job->completedAt = $this->completedAt; - $job->cbzPath = $this->cbzPath; - $job->failureReason = $this->failureReason; - - return $job; - } - - public function setStatus(string $status): void - { - $this->status = $status; - } - - public function setPages(array $pages): void - { - $this->pages = $pages; - } - - public function setCompletedAt(?\DateTimeImmutable $completedAt): void - { - $this->completedAt = $completedAt; - } - - public function setCbzPath(?string $cbzPath = null): void - { - $this->cbzPath = $cbzPath; - } - - public function setFailureReason(string $failureReason): void - { - $this->failureReason = $failureReason; - } -} diff --git a/src/Domain/Scraping/Infrastructure/Service/Scraper/AbstractScraper.php b/src/Domain/Scraping/Infrastructure/Service/Scraper/AbstractScraper.php deleted file mode 100644 index 4b8c4e2..0000000 --- a/src/Domain/Scraping/Infrastructure/Service/Scraper/AbstractScraper.php +++ /dev/null @@ -1,76 +0,0 @@ -isDir()) { - rmdir($file->getRealPath()); - } else { - unlink($file->getRealPath()); - } - } - rmdir($tempDir); - } - } - - protected function dispatchProgressEvent(ScrapingJob $job, int $currentPage, int $totalPages): void - { - $progress = new ScrapingProgress($currentPage, $totalPages); - $this->eventBus->dispatch(new PageScrapingProgressed($job->getId(), $progress)); - } - - protected function downloadImage(string $imageUrl, string $destination): void - { - $this->imageDownloader->download($imageUrl, $destination); - } - - protected function createTempDirectory(): TempDirectory - { - return new TempDirectory(sys_get_temp_dir() . '/' . uniqid('manga_scraper_')); - } - - protected function cleanupTempFiles(TempDirectory $tempDirectory): void - { - $files = glob($tempDirectory->getPath() . '/*'); - foreach ($files as $file) { - if (is_file($file)) { - unlink($file); - } - } - rmdir($tempDirectory->getPath()); - } - - abstract public function supports(string $sourceType): bool; -} diff --git a/src/Domain/Scraping/Infrastructure/Service/Scraper/HtmlScraper.php b/src/Domain/Scraping/Infrastructure/Service/Scraper/HtmlScraper.php index 79b6555..dc890cd 100644 --- a/src/Domain/Scraping/Infrastructure/Service/Scraper/HtmlScraper.php +++ b/src/Domain/Scraping/Infrastructure/Service/Scraper/HtmlScraper.php @@ -2,16 +2,7 @@ namespace App\Domain\Scraping\Infrastructure\Service\Scraper; -use App\Domain\Scraping\Domain\Contract\Repository\MangaRepositoryInterface; -use App\Domain\Scraping\Domain\Contract\Repository\ScrapingJobRepositoryInterface; -use App\Domain\Scraping\Domain\Model\ScrapingJob; -use App\Domain\Scraping\Domain\Model\Source; -use App\Domain\Scraping\Domain\Model\ValueObject\ImageUrl; -use App\Domain\Scraping\Domain\Model\ValueObject\PageNumber; -use App\Domain\Scraping\Domain\Contract\Repository\SourceRepositoryInterface; -use App\Domain\Scraping\Domain\Contract\Service\CbzGeneratorInterface; use App\Domain\Scraping\Domain\Contract\Service\ImageDownloaderInterface; -use App\Domain\Scraping\Domain\Model\ValueObject\ChapterUrl; use Symfony\Component\DomCrawler\Crawler; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Component\Messenger\MessageBusInterface; @@ -19,7 +10,6 @@ use App\Domain\Scraping\Domain\Contract\Service\ScraperInterface; use App\Domain\Scraping\Domain\Event\PageScrapingProgressed; use App\Domain\Scraping\Domain\Model\ValueObject\ScrapingRequest; use App\Domain\Scraping\Domain\Model\ValueObject\ScrapingResult; -use App\Domain\Scraping\Domain\Model\ValueObject\TempDirectory; use App\Domain\Scraping\Domain\Model\ScrapingProgress; class HtmlScraper implements ScraperInterface @@ -90,8 +80,6 @@ class HtmlScraper implements ScraperInterface $nextLink = $crawler->filter($params['nextPageSelector']); $currentUrl = $nextLink->count() > 0 ? $nextLink->attr('href') : null; - - $this->dispatchProgressEvent($request->getJobId(), count($pages), count($pages)); } return $pages; @@ -117,12 +105,4 @@ class HtmlScraper implements ScraperInterface { return preg_replace('/[\x00-\x1F\x7F]/', '', trim($url)); } - - private function dispatchProgressEvent(string $jobId, int $currentPage, int $totalPages): void - { - $this->eventBus->dispatch(new PageScrapingProgressed( - $jobId, - new ScrapingProgress($currentPage, $totalPages) - )); - } } diff --git a/src/Domain/Shared/Application/Query/ListJobsQuery.php b/src/Domain/Shared/Application/Query/ListJobsQuery.php new file mode 100644 index 0000000..edd187d --- /dev/null +++ b/src/Domain/Shared/Application/Query/ListJobsQuery.php @@ -0,0 +1,34 @@ +page < 1) { + throw new \InvalidArgumentException('Page must be greater than 0'); + } + if ($this->limit < 1) { + throw new \InvalidArgumentException('Limit must be greater than 0'); + } + } + + public function getOffset(): int + { + return ($this->page - 1) * $this->limit; + } +} diff --git a/src/Domain/Shared/Application/QueryHandler/ListJobsQueryHandler.php b/src/Domain/Shared/Application/QueryHandler/ListJobsQueryHandler.php new file mode 100644 index 0000000..3ad7a9b --- /dev/null +++ b/src/Domain/Shared/Application/QueryHandler/ListJobsQueryHandler.php @@ -0,0 +1,51 @@ + $query->status, + 'type' => $query->type, + 'createdAfter' => $query->createdAfter, + 'createdBefore' => $query->createdBefore, + 'sortBy' => $query->sortBy, + 'sortOrder' => $query->sortOrder, + 'offset' => $query->getOffset(), + 'limit' => $query->limit + ]; + + $jobs = $this->jobRepository->findByCriteria($criteria); + $total = $this->jobRepository->countByCriteria($criteria); + + return JobListResponse::fromJobs( + jobs: $jobs, + total: $total, + page: $query->page, + limit: $query->limit + ); + } +} diff --git a/src/Domain/Shared/Application/Response/JobListResponse.php b/src/Domain/Shared/Application/Response/JobListResponse.php new file mode 100644 index 0000000..1d232b8 --- /dev/null +++ b/src/Domain/Shared/Application/Response/JobListResponse.php @@ -0,0 +1,33 @@ + [ + [ + 'name' => 'status', + 'in' => 'query', + 'description' => 'Filtrer par status', + 'required' => false, + 'schema' => [ + 'type' => 'string', + 'enum' => ['pending', 'in_progress', 'completed', 'failed'], + 'example' => 'pending' + ] + ], + [ + 'name' => 'type', + 'in' => 'query', + 'description' => 'Filtrer par type de job (ex: scraping_job)', + 'required' => false, + 'schema' => ['type' => 'string'] + ], + [ + 'name' => 'createdAfter', + 'in' => 'query', + 'description' => 'Date de création minimum (format ISO8601)', + 'required' => false, + 'schema' => ['type' => 'string', 'format' => 'date-time'] + ], + [ + 'name' => 'createdBefore', + 'in' => 'query', + 'description' => 'Date de création maximum (format ISO8601)', + 'required' => false, + 'schema' => ['type' => 'string', 'format' => 'date-time'] + ], + [ + 'name' => 'page', + 'in' => 'query', + 'description' => 'Numéro de la page', + 'required' => false, + 'schema' => ['type' => 'integer', 'default' => 1, 'minimum' => 1] + ], + [ + 'name' => 'limit', + 'in' => 'query', + 'description' => 'Nombre d\'éléments par page', + 'required' => false, + 'schema' => ['type' => 'integer', 'default' => 20, 'minimum' => 1] + ], + [ + 'name' => 'sortBy', + 'in' => 'query', + 'description' => 'Champ de tri', + 'required' => false, + 'schema' => [ + 'type' => 'string', + 'enum' => ['createdAt', 'type', 'status'], + 'default' => 'createdAt' + ] + ], + [ + 'name' => 'sortOrder', + 'in' => 'query', + 'description' => 'Ordre de tri', + 'required' => false, + 'schema' => [ + 'type' => 'string', + 'enum' => ['ASC', 'DESC'], + 'default' => 'DESC' + ] + ] + ] + ] + ) + ] +)] +class GetJobListResource +{ + public function __construct( + #[ApiProperty( + identifier: true, + description: 'Identifiant unique du job' + )] + public readonly string $id, + + #[ApiProperty(description: 'Type du job (ex: scraping_job)')] + #[Assert\NotBlank] + public readonly string $type, + + #[ApiProperty( + description: 'Status du job', + openapiContext: ['enum' => ['pending', 'in_progress', 'completed', 'failed', 'cancelled']] + )] + #[Assert\NotBlank] + public readonly string $status, + + #[ApiProperty(description: 'Date de création du job')] + #[Assert\NotNull] + public readonly \DateTimeImmutable $createdAt, + + #[ApiProperty(description: 'Date de début d\'exécution du job')] + public readonly ?\DateTimeImmutable $startedAt = null, + + #[ApiProperty(description: 'Date de fin d\'exécution du job')] + public readonly ?\DateTimeImmutable $completedAt = null, + + #[ApiProperty(description: 'Raison de l\'échec si le job a échoué')] + public readonly ?string $failureReason = null, + + #[ApiProperty(description: 'Nombre de tentatives effectuées')] + #[Assert\GreaterThanOrEqual(0)] + public readonly int $attempts = 0, + + #[ApiProperty(description: 'Nombre maximum de tentatives autorisées')] + #[Assert\GreaterThan(0)] + public readonly int $maxAttempts = 3, + + #[ApiProperty(description: 'Données contextuelles du job')] + public readonly array $context = [] + ) {} + + public static function fromJob(\App\Domain\Shared\Domain\Model\Job $job): self + { + return new self( + id: $job->id, + type: $job->type, + status: $job->status->value, + createdAt: $job->createdAt, + startedAt: $job->startedAt, + completedAt: $job->completedAt, + failureReason: $job->failureReason, + attempts: $job->attempts, + maxAttempts: $job->maxAttempts, + context: $job->context + ); + } +} diff --git a/src/Domain/Shared/Infrastructure/ApiPlatform/State/Provider/GetJobListStateProvider.php b/src/Domain/Shared/Infrastructure/ApiPlatform/State/Provider/GetJobListStateProvider.php new file mode 100644 index 0000000..d911612 --- /dev/null +++ b/src/Domain/Shared/Infrastructure/ApiPlatform/State/Provider/GetJobListStateProvider.php @@ -0,0 +1,48 @@ +handler->handle($query); + + return [ + 'items' => array_map( + fn($job) => GetJobListResource::fromJob($job), + $response->items + ), + 'total' => $response->total, + 'page' => $response->page, + 'limit' => $response->limit, + 'pages' => $response->pages + ]; + } +} diff --git a/src/Domain/Shared/Infrastructure/Persistence/Entity/FailedJobEntity.php b/src/Domain/Shared/Infrastructure/Persistence/Entity/FailedJobEntity.php new file mode 100644 index 0000000..6d00bbc --- /dev/null +++ b/src/Domain/Shared/Infrastructure/Persistence/Entity/FailedJobEntity.php @@ -0,0 +1,81 @@ +id; + } + + public function setId(string $id): self + { + $this->id = $id; + return $this; + } + + public function getType(): string + { + return $this->type; + } + + public function setType(string $type): self + { + $this->type = $type; + return $this; + } + + public function getFailureReason(): string + { + return $this->failureReason; + } + + public function setFailureReason(string $failureReason): self + { + $this->failureReason = $failureReason; + return $this; + } + + public function getFailedAt(): \DateTimeImmutable + { + return $this->failedAt; + } + + public function setFailedAt(\DateTimeImmutable $failedAt): self + { + $this->failedAt = $failedAt; + return $this; + } + + public function getContext(): array + { + return $this->context; + } + + public function setContext(array $context): self + { + $this->context = $context; + return $this; + } +} diff --git a/src/Domain/Shared/Infrastructure/Persistence/Entity/JobEntity.php b/src/Domain/Shared/Infrastructure/Persistence/Entity/JobEntity.php new file mode 100644 index 0000000..e2b8682 --- /dev/null +++ b/src/Domain/Shared/Infrastructure/Persistence/Entity/JobEntity.php @@ -0,0 +1,151 @@ +id; + } + + public function setId(string $id): self + { + $this->id = $id; + return $this; + } + + public function getType(): string + { + return $this->type; + } + + public function setType(string $type): self + { + $this->type = $type; + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setStatus(string $status): self + { + $this->status = $status; + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): self + { + $this->createdAt = $createdAt; + return $this; + } + + public function getStartedAt(): ?\DateTimeImmutable + { + return $this->startedAt; + } + + public function setStartedAt(?\DateTimeImmutable $startedAt): self + { + $this->startedAt = $startedAt; + return $this; + } + + public function getCompletedAt(): ?\DateTimeImmutable + { + return $this->completedAt; + } + + public function setCompletedAt(?\DateTimeImmutable $completedAt): self + { + $this->completedAt = $completedAt; + return $this; + } + + public function getFailureReason(): ?string + { + return $this->failureReason; + } + + public function setFailureReason(?string $failureReason): self + { + $this->failureReason = $failureReason; + return $this; + } + + public function getAttempts(): int + { + return $this->attempts; + } + + public function setAttempts(int $attempts): self + { + $this->attempts = $attempts; + return $this; + } + + public function getMaxAttempts(): int + { + return $this->maxAttempts; + } + + public function setMaxAttempts(int $maxAttempts): self + { + $this->maxAttempts = $maxAttempts; + return $this; + } + + public function getContext(): array + { + return $this->context; + } + + public function setContext(array $context): self + { + $this->context = $context; + return $this; + } +} diff --git a/src/Domain/Shared/Infrastructure/Persistence/Mapper/FailedJobMapper.php b/src/Domain/Shared/Infrastructure/Persistence/Mapper/FailedJobMapper.php new file mode 100644 index 0000000..3041e51 --- /dev/null +++ b/src/Domain/Shared/Infrastructure/Persistence/Mapper/FailedJobMapper.php @@ -0,0 +1,34 @@ +setId($job->id) + ->setType($job->jobType) + ->setFailureReason($job->failureReason) + ->setFailedAt($job->failedAt) + ->setContext($job->context); + + return $entity; + } + + public function toDomain(FailedJobEntity $entity): FailedJob + { + return new FailedJob( + id: $entity->getId(), + jobId: $entity->getId(), // On utilise le même ID car on n'a pas de référence au job original + jobType: $entity->getType(), + failureReason: $entity->getFailureReason(), + context: $entity->getContext(), + failedAt: $entity->getFailedAt(), + attempt: 1 // Par défaut car on n'a pas cette info dans l'entité + ); + } +} diff --git a/src/Domain/Shared/Infrastructure/Persistence/Mapper/JobMapper.php b/src/Domain/Shared/Infrastructure/Persistence/Mapper/JobMapper.php new file mode 100644 index 0000000..1babb10 --- /dev/null +++ b/src/Domain/Shared/Infrastructure/Persistence/Mapper/JobMapper.php @@ -0,0 +1,52 @@ +setId($job->id) + ->setType($job->type) + ->setStatus($job->status->value) + ->setCreatedAt($job->createdAt) + ->setStartedAt($job->startedAt) + ->setCompletedAt($job->completedAt) + ->setFailureReason($job->failureReason) + ->setAttempts($job->attempts) + ->setMaxAttempts($job->maxAttempts) + ->setContext($job->context); + + return $entity; + } + + public function toDomain(JobEntity $entity): Job + { + $job = match($entity->getType()) { + 'scraping_job' => new ScrapingJob( + $entity->getId(), + $entity->getContext()['mangaId'], + $entity->getContext()['chapterNumber'], + $entity->getContext()['sourceId'] + ), + default => throw new \RuntimeException(sprintf('Unknown job type: %s', $entity->getType())) + }; + + $job->status = JobStatus::from($entity->getStatus()); + $job->createdAt = $entity->getCreatedAt(); + $job->startedAt = $entity->getStartedAt(); + $job->completedAt = $entity->getCompletedAt(); + $job->failureReason = $entity->getFailureReason(); + $job->attempts = $entity->getAttempts(); + $job->maxAttempts = $entity->getMaxAttempts(); + $job->context = $entity->getContext(); + + return $job; + } +} diff --git a/src/Domain/Shared/Infrastructure/Persistence/Repository/DoctrineFailedJobRepository.php b/src/Domain/Shared/Infrastructure/Persistence/Repository/DoctrineFailedJobRepository.php new file mode 100644 index 0000000..9c1d8ef --- /dev/null +++ b/src/Domain/Shared/Infrastructure/Persistence/Repository/DoctrineFailedJobRepository.php @@ -0,0 +1,104 @@ +mapper->toEntity($job); + $this->entityManager->persist($entity); + $this->entityManager->flush(); + } + + public function get(string $id): FailedJob + { + $job = $this->findById($id); + + if (null === $job) { + throw new \RuntimeException(sprintf('Failed job with id %s not found', $id)); + } + + return $job; + } + + public function delete(string $id): void + { + $entity = $this->entityManager->find(FailedJobEntity::class, $id); + + if ($entity) { + $this->entityManager->remove($entity); + $this->entityManager->flush(); + } + } + + public function findAll(): array + { + $entities = $this->entityManager->createQueryBuilder() + ->select('j') + ->from(FailedJobEntity::class, 'j') + ->getQuery() + ->getResult(); + + return array_map(fn(FailedJobEntity $entity) => $this->mapper->toDomain($entity), $entities); + } + + public function findById(string $id): ?FailedJob + { + $entity = $this->entityManager->find(FailedJobEntity::class, $id); + + if (null === $entity) { + return null; + } + + return $this->mapper->toDomain($entity); + } + + public function findByJobType(string $type): array + { + return $this->findByType($type); + } + + public function findRetryableJobs(): array + { + $entities = $this->entityManager->createQueryBuilder() + ->select('j') + ->from(FailedJobEntity::class, 'j') + ->getQuery() + ->getResult(); + + return array_map( + fn(FailedJobEntity $entity) => $this->mapper->toDomain($entity), + array_filter( + $entities, + fn(FailedJobEntity $entity) => $this->mapper->toDomain($entity)->attempt < 3 + ) + ); + } + + private function findByType(string $type): array + { + $entities = $this->entityManager->createQueryBuilder() + ->select('j') + ->from(FailedJobEntity::class, 'j') + ->where('j.type = :type') + ->setParameter('type', $type) + ->getQuery() + ->getResult(); + + return array_map(fn(FailedJobEntity $entity) => $this->mapper->toDomain($entity), $entities); + } +} diff --git a/src/Domain/Shared/Infrastructure/Persistence/Repository/DoctrineJobRepository.php b/src/Domain/Shared/Infrastructure/Persistence/Repository/DoctrineJobRepository.php new file mode 100644 index 0000000..6a25995 --- /dev/null +++ b/src/Domain/Shared/Infrastructure/Persistence/Repository/DoctrineJobRepository.php @@ -0,0 +1,180 @@ +entityManager->find(JobEntity::class, $job->id); + + if ($existingJobEntity) { + dump('existingJobEntity', $existingJobEntity); + $existingJobEntity->setStatus($job->status->value); + $existingJobEntity->setStartedAt($job->startedAt); + $existingJobEntity->setCompletedAt($job->completedAt); + $existingJobEntity->setFailureReason($job->failureReason); + $existingJobEntity->setAttempts($job->attempts); + $existingJobEntity->setContext($job->context); + $this->entityManager->persist($existingJobEntity); + dump('updated', $existingJobEntity); + } else { + $entity = $this->mapper->toEntity($job); + $this->entityManager->persist($entity); + dump('created', $entity); + } + $this->entityManager->flush(); + dump('flushed'); + } + + public function get(string $id): Job + { + $job = $this->findById($id); + + if (null === $job) { + throw JobNotFoundException::withId($id); + } + + return $job; + } + + public function findById(string $id): ?Job + { + $entity = $this->entityManager->find(JobEntity::class, $id); + + if (null === $entity) { + return null; + } + + return $this->mapper->toDomain($entity); + } + + public function findByStatus(JobStatus $status): array + { + $entities = $this->entityManager->createQueryBuilder() + ->select('j') + ->from(JobEntity::class, 'j') + ->where('j.status = :status') + ->setParameter('status', $status->value) + ->getQuery() + ->getResult(); + + return array_map(fn(JobEntity $entity) => $this->mapper->toDomain($entity), $entities); + } + + public function findPendingJobs(): array + { + return $this->findByStatus(JobStatus::PENDING); + } + + public function findInProgressJobs(): array + { + return $this->findByStatus(JobStatus::IN_PROGRESS); + } + + public function findFailedJobs(): array + { + return $this->findByStatus(JobStatus::FAILED); + } + + public function findByType(string $type): array + { + $entities = $this->entityManager->createQueryBuilder() + ->select('j') + ->from(JobEntity::class, 'j') + ->where('j.type = :type') + ->setParameter('type', $type) + ->getQuery() + ->getResult(); + + return array_map(fn(JobEntity $entity) => $this->mapper->toDomain($entity), $entities); + } + + public function findByCriteria(array $criteria): array + { + $qb = $this->entityManager->createQueryBuilder() + ->select('j') + ->from(JobEntity::class, 'j'); + + if (isset($criteria['status'])) { + $qb->andWhere('j.status = :status') + ->setParameter('status', $criteria['status']->value); + } + + if (isset($criteria['type'])) { + $qb->andWhere('j.type = :type') + ->setParameter('type', $criteria['type']); + } + + if (isset($criteria['createdAfter'])) { + $qb->andWhere('j.createdAt >= :createdAfter') + ->setParameter('createdAfter', $criteria['createdAfter']); + } + + if (isset($criteria['createdBefore'])) { + $qb->andWhere('j.createdAt <= :createdBefore') + ->setParameter('createdBefore', $criteria['createdBefore']); + } + + if (isset($criteria['sortBy'])) { + $qb->orderBy('j.' . $criteria['sortBy'], $criteria['sortOrder'] ?? 'ASC'); + } + + if (isset($criteria['offset'])) { + $qb->setFirstResult($criteria['offset']); + } + + if (isset($criteria['limit'])) { + $qb->setMaxResults($criteria['limit']); + } + + $entities = $qb->getQuery()->getResult(); + + return array_map(fn(JobEntity $entity) => $this->mapper->toDomain($entity), $entities); + } + + public function countByCriteria(array $criteria): int + { + $qb = $this->entityManager->createQueryBuilder() + ->select('COUNT(j.id)') + ->from(JobEntity::class, 'j'); + + if (isset($criteria['status'])) { + $qb->andWhere('j.status = :status') + ->setParameter('status', $criteria['status']->value); + } + + if (isset($criteria['type'])) { + $qb->andWhere('j.type = :type') + ->setParameter('type', $criteria['type']); + } + + if (isset($criteria['createdAfter'])) { + $qb->andWhere('j.createdAt >= :createdAfter') + ->setParameter('createdAfter', $criteria['createdAfter']); + } + + if (isset($criteria['createdBefore'])) { + $qb->andWhere('j.createdAt <= :createdBefore') + ->setParameter('createdBefore', $criteria['createdBefore']); + } + + return (int) $qb->getQuery()->getSingleScalarResult(); + } +}