6 Commits

Author SHA1 Message Date
ext.jeremy.guillot@maxicoffee.domains
aba8e36231 perf(reader): virtual rendering avec IntersectionObserver en mode scroll
Remplace le rendu de tous les composants ReaderPage par un système de
virtual rendering : seules les pages dans la zone ±1000px du viewport
sont montées, les autres sont remplacées par un placeholder dimensionné.

- InfiniteReader : ajout visibilityObserver + mountedPageIndices (Set
  réactif), helper getPlaceholderHeight(), suppression de 5 console.log
- ReaderPage : prop windowWidth injectable depuis le parent, listener
  resize conditionnel, suppression de 3 console.log de debug
2026-03-15 18:51:06 +01:00
c268b2c312 Merge pull request 'fix(import): extraire les images CBZ vers le stockage individuel' (#15) from fix/import-cbz-image-storage into main
All checks were successful
Deploy / deploy (push) Successful in 3m3s
Reviewed-on: #15
2026-03-15 18:27:28 +01:00
c060e7b95e Merge branch 'main' into fix/import-cbz-image-storage 2026-03-15 18:27:07 +01:00
ext.jeremy.guillot@maxicoffee.domains
2e3abb76c3 fix(import): extraire les images CBZ vers le stockage individuel
Corrige l'import de chapitres/volumes CBZ qui stockait le chemin du fichier
CBZ comme pagesDirectory. Le reader ne trouvait aucune image car
LegacyChapterRepository attend un dossier d'images individuelles.

- Déplace ImageStorageInterface dans Shared (storeChapterImages + extractFromCbz + countCbzImages)
- Crée ImageStorageManager dans Shared/Infrastructure (extraction ZIP + copie)
- Supprime LocalImageStorage et l'ancienne interface dans Scraping
- Refactore ImportChapterHandler et ImportVolumeHandler pour utiliser ImageStorageInterface
- Corrige LegacyChapterRepository : construit l'URL depuis basename(pagesDirectory)
  au lieu de chapterId (fix pour les volumes partagés)
2026-03-15 18:26:28 +01:00
b40892b924 Merge pull request 'perf(reader): windowing + eager loading sur l'InfiniteReader' (#14) from perf/reader-lazy-loading into main
All checks were successful
Deploy / deploy (push) Successful in 2m51s
Reviewed-on: #14
2026-03-15 17:51:07 +01:00
ext.jeremy.guillot@maxicoffee.domains
74f033f5d1 perf(reader): windowing + eager loading sur l'InfiniteReader
- Windowing côté rendu : seules les pages dans une fenêtre de ±3 autour
  de la page visible sont montées en tant que ReaderPage ; les autres
  sont remplacées par des placeholders dimensionnés via aspect-ratio CSS
  pour maintenir la hauteur de scroll sans saut
- IntersectionObserver utilise le minimum des indices intersectants pour
  éviter que les entrées simultanées au chargement ne décalent la fenêtre
- Prop initialPage passé depuis ChapterReader pour ancrer la fenêtre sur
  la page courante dès le montage
- loading="eager" sur les ReaderPage montés (le windowing est le mécanisme
  de lazy-loading, pas l'attribut HTML natif)
- Prop loading bindé sur les 3 balises <img> de ReaderPage
2026-03-15 17:46:00 +01:00
17 changed files with 299 additions and 153 deletions

41
TASK.md
View File

@@ -75,6 +75,47 @@
--- ---
## [Perf] Reader — Lazy-loading des pages (InfiniteReader)
**Problème :** `readerStore.js` charge toutes les pages avec `itemsPerPage=9999`. `InfiniteReader.vue` monte tous les composants `ReaderPage` simultanément dans le DOM. Sur un chapitre de 200 pages, cela représente 200 composants actifs et autant d'images pré-chargées.
- [ ] Implémenter un `IntersectionObserver` sur les wrappers de page pour ne charger les images qu'au moment où elles entrent dans le viewport (`loading="lazy"` ou src conditionnel)
- [ ] Limiter le nombre de composants montés simultanément (virtualisation ou windowing) : ne rendre que les pages proches de la page courante (ex. fenêtre de ±3 pages)
- [ ] Adapter `readerStore.js` : remplacer `itemsPerPage=9999` par la vraie pagination côté API si la virtualisation le justifie, sinon conserver le fetch unique mais différer le rendu
- [ ] Vérifier que le mode `single` n'est pas impacté (il affiche déjà une seule page)
---
## [Bug] Reader — N+1 requêtes SQL dans `getChapterContext()`
**Problème :** `LegacyChapterRepository::getChapterContext()` émet 5 requêtes SQL pour un seul chargement : la requête principale + 2 doublons dans `getPreviousChapterId()` / `getNextChapterId()` (chacune re-fetche le chapitre courant) + les 2 requêtes de navigation.
- [ ] Refactorer `getPreviousChapterId()` et `getNextChapterId()` pour accepter l'entité `ChapterEntity` déjà chargée en paramètre (au lieu de re-fetcher par ID)
- [ ] Appeler ces méthodes depuis `getChapterContext()` en passant l'entité déjà disponible
- [ ] Résultat attendu : 3 requêtes maximum (1 pour le chapitre courant + 1 prev + 1 next), idéalement 1 seule avec une requête SQL combinée
---
## [Bug] Reader — Division par zéro dans `ChapterPagesResponse::getTotalPages()`
**Problème :** `ceil($totalItems / $itemsPerPage)` crashe si `itemsPerPage = 0`. Le test existant documente le bug avec un TODO et assert un HTTP 500 au lieu de corriger.
- [ ] Ajouter une validation dans `ChapterPagesProvider` : rejeter la requête avec HTTP 400 si `itemsPerPage <= 0`
- [ ] Corriger le test `GetChapterPagesTest` pour vérifier HTTP 400 (et non 500)
- [ ] Supprimer le commentaire TODO du test une fois corrigé
---
## [Bug] Reader — `totalPages` toujours égal à 0 dans `ChapterContext`
**Problème :** `LegacyChapterRepository::getChapterContext()` hardcode `totalPages: 0`. La méthode `getTotalPagesForChapter()` existe mais n'est jamais appelée depuis `GetChapterContextHandler`.
- [ ] Appeler `getTotalPagesForChapter()` dans `getChapterContext()` (ou dans le handler) pour calculer le vrai nombre de pages
- [ ] Vérifier que la valeur est correctement sérialisée dans la réponse API Platform (`ChapterContextResponse`)
- [ ] Adapter les tests existants qui pourraient asserter `totalPages: 0`
---
## [Style] Page conversion CBR → CBZ — Simplification UI + notifications toast ## [Style] Page conversion CBR → CBZ — Simplification UI + notifications toast
**Objectif :** Revoir le style de la page de conversion CBR → CBZ pour le simplifier, et remplacer le message statique "Conversion réussie" par les notifications toast de l'application. **Objectif :** Revoir le style de la page de conversion CBR → CBZ pour le simplifier, et remplacer le message statique "Conversion réussie" par les notifications toast de l'application.

View File

@@ -22,6 +22,7 @@
:pages="store.pages" :pages="store.pages"
:zoom="store.zoom" :zoom="store.zoom"
:double-page-mode="store.effectiveDoublePageMode" :double-page-mode="store.effectiveDoublePageMode"
:initial-page="store.currentPage"
@page-visible="store.handlePageVisible" @page-visible="store.handlePageVisible"
ref="infiniteReaderRef" /> ref="infiniteReaderRef" />
</template> </template>

View File

@@ -1,10 +1,26 @@
<template> <template>
<div class="infinite-reader" ref="containerRef"> <div class="infinite-reader" ref="containerRef">
<div v-for="(page, index) in pages" :key="index" class="page-wrapper"> <div v-for="(page, index) in pages" :key="index"
class="page-wrapper" :data-page-index="index">
<!-- Pas d'URL : spinner de chargement -->
<div v-if="!page?.url" class="loading"> <div v-if="!page?.url" class="loading">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div> <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div> </div>
<ReaderPage v-else :page-data="page" :page-number="index + 1" :zoom="zoom" :double-page-mode="doublePageMode" loading="lazy" />
<!-- Hors de la zone de rendu : placeholder dimensionné -->
<div v-else-if="!mountedPageIndices.has(index)"
class="page-placeholder"
:style="{ height: getPlaceholderHeight(page) + 'px' }" />
<!-- Dans la zone : composant complet -->
<ReaderPage v-else
:page-data="page"
:page-number="index + 1"
:zoom="zoom"
:double-page-mode="doublePageMode"
:window-width="windowWidth"
loading="lazy" />
</div> </div>
<!-- Bouton flottant pour revenir en haut --> <!-- Bouton flottant pour revenir en haut -->
@@ -33,7 +49,7 @@
</template> </template>
<script setup> <script setup>
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'; import { nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { useHeaderStore } from '../../../../shared/stores/headerStore'; import { useHeaderStore } from '../../../../shared/stores/headerStore';
import ReaderPage from './ReaderPage.vue'; import ReaderPage from './ReaderPage.vue';
@@ -57,6 +73,8 @@ import ReaderPage from './ReaderPage.vue';
const headerStore = useHeaderStore(); const headerStore = useHeaderStore();
const containerRef = ref(null); const containerRef = ref(null);
const observer = ref(null); const observer = ref(null);
const visibilityObserver = ref(null);
const mountedPageIndices = reactive(new Set());
const windowWidth = ref(window.innerWidth); const windowWidth = ref(window.innerWidth);
// État unique pour tous les boutons flottants avec timer de 3 secondes // État unique pour tous les boutons flottants avec timer de 3 secondes
@@ -76,24 +94,46 @@ import ReaderPage from './ReaderPage.vue';
}); });
}; };
const setupIntersectionObserver = () => { // Calcul de la hauteur du placeholder — miroir exact du maxWidth de ReaderPage
if (observer.value) { const getPlaceholderHeight = (page) => {
observer.value.disconnect(); const dims = page?.dimensions;
} if (!dims?.width || !dims?.height) return 800;
const displayWidth = windowWidth.value < 1200
? Math.min(dims.width, windowWidth.value * 0.95)
: Math.min(dims.width, 1200);
return Math.round((dims.height / dims.width) * displayWidth);
};
const setupObservers = () => {
observer.value?.disconnect();
visibilityObserver.value?.disconnect();
observer.value = new IntersectionObserver(observeIntersection, { observer.value = new IntersectionObserver(observeIntersection, {
root: null, root: null,
threshold: 0.5 threshold: 0.5
}); });
nextTick(() => { visibilityObserver.value = new IntersectionObserver(
const pageElements = containerRef.value?.querySelectorAll('.page-wrapper'); (entries) => {
if (pageElements) { entries.forEach(entry => {
pageElements.forEach((element, index) => { const idx = parseInt(entry.target.getAttribute('data-page-index'));
element.setAttribute('data-page-index', index); if (entry.isIntersecting) {
observer.value.observe(element); mountedPageIndices.add(idx);
} else {
mountedPageIndices.delete(idx);
}
}); });
} },
{ root: null, rootMargin: '1000px 0px', threshold: 0 }
);
nextTick(() => {
const els = containerRef.value?.querySelectorAll('.page-wrapper');
els?.forEach((el, i) => {
el.setAttribute('data-page-index', i);
observer.value.observe(el);
visibilityObserver.value.observe(el);
});
}); });
}; };
@@ -177,21 +217,16 @@ import ReaderPage from './ReaderPage.vue';
// Fonction pour revenir en haut de la page // Fonction pour revenir en haut de la page
const scrollToTop = () => { const scrollToTop = () => {
console.log('scrollToTop appelée'); // Debug
// Réinitialiser le timer lors du clic // Réinitialiser le timer lors du clic
resetButtonsTimer(); resetButtonsTimer();
// Stratégie 1: Scroll sur le conteneur direct // Stratégie 1: Scroll sur le conteneur direct
if (containerRef.value) { if (containerRef.value) {
console.log('containerRef trouvé, scrollTop actuel:', containerRef.value.scrollTop); // Debug
if (containerRef.value.scrollTop > 0) { if (containerRef.value.scrollTop > 0) {
containerRef.value.scrollTo({ containerRef.value.scrollTo({
top: 0, top: 0,
behavior: 'smooth' behavior: 'smooth'
}); });
console.log('Scroll sur containerRef effectué'); // Debug
return; return;
} }
} }
@@ -201,7 +236,6 @@ import ReaderPage from './ReaderPage.vue';
while (currentElement) { while (currentElement) {
const styles = window.getComputedStyle(currentElement); const styles = window.getComputedStyle(currentElement);
if (styles.overflowY === 'auto' || styles.overflowY === 'scroll' || currentElement.scrollTop > 0) { if (styles.overflowY === 'auto' || styles.overflowY === 'scroll' || currentElement.scrollTop > 0) {
console.log('Conteneur avec scroll trouvé:', currentElement.className, 'scrollTop:', currentElement.scrollTop); // Debug
currentElement.scrollTo({ currentElement.scrollTo({
top: 0, top: 0,
behavior: 'smooth' behavior: 'smooth'
@@ -212,7 +246,6 @@ import ReaderPage from './ReaderPage.vue';
} }
// Stratégie 3: Scroll sur la fenêtre entière // Stratégie 3: Scroll sur la fenêtre entière
console.log('Scroll sur window, scrollY actuel:', window.scrollY); // Debug
window.scrollTo({ window.scrollTo({
top: 0, top: 0,
behavior: 'smooth' behavior: 'smooth'
@@ -228,7 +261,8 @@ import ReaderPage from './ReaderPage.vue';
watch( watch(
() => props.pages, () => props.pages,
() => { () => {
setupIntersectionObserver(); mountedPageIndices.clear();
setupObservers();
}, },
{ immediate: true } { immediate: true }
); );
@@ -247,7 +281,7 @@ import ReaderPage from './ReaderPage.vue';
}; };
onMounted(() => { onMounted(() => {
setupIntersectionObserver(); setupObservers();
// Activer l'auto-hide du header si la largeur < 1200px // Activer l'auto-hide du header si la largeur < 1200px
if (windowWidth.value < 1200) { if (windowWidth.value < 1200) {
@@ -267,9 +301,8 @@ import ReaderPage from './ReaderPage.vue';
}); });
onUnmounted(() => { onUnmounted(() => {
if (observer.value) { observer.value?.disconnect();
observer.value.disconnect(); visibilityObserver.value?.disconnect();
}
// Désactiver l'auto-hide du header en quittant // Désactiver l'auto-hide du header en quittant
headerStore.disableAutoHide(); headerStore.disableAutoHide();
@@ -303,6 +336,12 @@ import ReaderPage from './ReaderPage.vue';
@apply mb-2 sm:mb-4 px-1 sm:px-4; @apply mb-2 sm:mb-4 px-1 sm:px-4;
} }
.page-placeholder {
@apply w-full;
max-width: 1200px;
min-height: 400px;
}
.loading, .loading,
.error { .error {
@apply flex items-center justify-center min-h-[400px]; @apply flex items-center justify-center min-h-[400px];

View File

@@ -78,6 +78,10 @@ import { useReaderStore } from '../../application/store/readerStore';
type: String, type: String,
default: 'rotate', // 'rotate', 'scroll', 'normal' default: 'rotate', // 'rotate', 'scroll', 'normal'
validator: (value) => ['rotate', 'scroll', 'normal'].includes(value) validator: (value) => ['rotate', 'scroll', 'normal'].includes(value)
},
windowWidth: {
type: Number,
default: null
} }
}); });
@@ -96,8 +100,11 @@ import { useReaderStore } from '../../application/store/readerStore';
const scrollContainerRef = ref(null); const scrollContainerRef = ref(null);
const naturalWidth = ref(0); const naturalWidth = ref(0);
const naturalHeight = ref(0); const naturalHeight = ref(0);
const windowWidth = ref(window.innerWidth); const localWindowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value < 768); const effectiveWindowWidth = computed(() =>
props.windowWidth !== null ? props.windowWidth : localWindowWidth.value
);
const isMobile = computed(() => effectiveWindowWidth.value < 768);
const imageLoaded = ref(false); const imageLoaded = ref(false);
const imageSource = computed(() => { const imageSource = computed(() => {
@@ -116,17 +123,13 @@ import { useReaderStore } from '../../application/store/readerStore';
// Utiliser d'abord les dimensions de l'API si disponibles // Utiliser d'abord les dimensions de l'API si disponibles
if (props.pageData?.dimensions?.width && props.pageData?.dimensions?.height) { if (props.pageData?.dimensions?.width && props.pageData?.dimensions?.height) {
const ratio = props.pageData.dimensions.width / props.pageData.dimensions.height; const ratio = props.pageData.dimensions.width / props.pageData.dimensions.height;
const isDouble = ratio > threshold; return ratio > threshold;
console.log(`API Dimensions - Page ${props.pageNumber}: ${props.pageData.dimensions.width}x${props.pageData.dimensions.height}, ratio: ${ratio.toFixed(2)}, isDouble: ${isDouble}`);
return isDouble;
} }
// Fallback sur les dimensions naturelles de l'image (seulement si l'image est chargée) // Fallback sur les dimensions naturelles de l'image (seulement si l'image est chargée)
if (imageLoaded.value && naturalWidth.value && naturalHeight.value) { if (imageLoaded.value && naturalWidth.value && naturalHeight.value) {
const ratio = naturalWidth.value / naturalHeight.value; const ratio = naturalWidth.value / naturalHeight.value;
const isDouble = ratio > threshold; return ratio > threshold;
console.log(`Natural Dimensions - Page ${props.pageNumber}: ${naturalWidth.value}x${naturalHeight.value}, ratio: ${ratio.toFixed(2)}, isDouble: ${isDouble}`);
return isDouble;
} }
return false; return false;
@@ -137,7 +140,6 @@ import { useReaderStore } from '../../application/store/readerStore';
naturalWidth.value = imageRef.value.naturalWidth; naturalWidth.value = imageRef.value.naturalWidth;
naturalHeight.value = imageRef.value.naturalHeight; naturalHeight.value = imageRef.value.naturalHeight;
imageLoaded.value = true; imageLoaded.value = true;
console.log(`Image loaded - Page ${props.pageNumber}: ${naturalWidth.value}x${naturalHeight.value}`);
// Positionner le scroll à droite si c'est le mode scroll // Positionner le scroll à droite si c'est le mode scroll
if (props.doublePageMode === 'scroll' && scrollContainerRef.value) { if (props.doublePageMode === 'scroll' && scrollContainerRef.value) {
@@ -188,7 +190,7 @@ import { useReaderStore } from '../../application/store/readerStore';
if (!width || !height) return null; if (!width || !height) return null;
const availableWidth = windowWidth.value; const availableWidth = effectiveWindowWidth.value;
// Si la largeur disponible est < 1200px : utiliser 95% de la largeur // Si la largeur disponible est < 1200px : utiliser 95% de la largeur
if (availableWidth < 1200) { if (availableWidth < 1200) {
@@ -237,7 +239,7 @@ import { useReaderStore } from '../../application/store/readerStore';
if (!width || !height) return {}; if (!width || !height) return {};
// En mode rotation : maximiser l'utilisation de l'espace // En mode rotation : maximiser l'utilisation de l'espace
const availableWidth = windowWidth.value; const availableWidth = effectiveWindowWidth.value;
const availableHeight = window.innerHeight - 100; // Laisser un peu d'espace pour les contrôles const availableHeight = window.innerHeight - 100; // Laisser un peu d'espace pour les contrôles
// Après rotation, la largeur originale devient la hauteur affichée // Après rotation, la largeur originale devient la hauteur affichée
@@ -287,20 +289,18 @@ import { useReaderStore } from '../../application/store/readerStore';
}; };
}); });
// Gestion du redimensionnement de la fenêtre let ownResizeHandler = null;
const handleResize = () => {
windowWidth.value = window.innerWidth;
};
onMounted(() => { onMounted(() => {
if (imageRef.value && imageRef.value.complete) { if (props.windowWidth === null) {
handleImageLoad(); ownResizeHandler = () => { localWindowWidth.value = window.innerWidth; };
window.addEventListener('resize', ownResizeHandler, { passive: true });
} }
window.addEventListener('resize', handleResize); if (imageRef.value?.complete) handleImageLoad();
}); });
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', handleResize); if (ownResizeHandler) window.removeEventListener('resize', ownResizeHandler);
}); });
</script> </script>

View File

@@ -126,10 +126,10 @@ services:
tags: tags:
- { name: messenger.message_handler, bus: command.bus } - { name: messenger.message_handler, bus: command.bus }
App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface: App\Domain\Shared\Domain\Contract\ImageStorageInterface:
alias: App\Domain\Scraping\Infrastructure\Service\LocalImageStorage alias: App\Domain\Shared\Infrastructure\Service\ImageStorageManager
App\Domain\Scraping\Infrastructure\Service\LocalImageStorage: App\Domain\Shared\Infrastructure\Service\ImageStorageManager:
arguments: arguments:
$storagePath: '%kernel.project_dir%/public/images' $storagePath: '%kernel.project_dir%/public/images'

View File

@@ -12,7 +12,7 @@ services:
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryScrapingJobRepository' class: 'App\Tests\Domain\Scraping\Adapter\InMemoryScrapingJobRepository'
public: true public: true
App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface: App\Domain\Shared\Domain\Contract\ImageStorageInterface:
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage' class: 'App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage'
public: true public: true

View File

@@ -6,13 +6,13 @@ use App\Domain\Manga\Application\Command\ImportChapter;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface; use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException; use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException; use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface; use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
readonly class ImportChapterHandler readonly class ImportChapterHandler
{ {
public function __construct( public function __construct(
private MangaRepositoryInterface $mangaRepository, private MangaRepositoryInterface $mangaRepository,
private MangaPathManagerInterface $pathManager private ImageStorageInterface $imageStorage
) { ) {
} }
@@ -39,11 +39,15 @@ readonly class ImportChapterHandler
throw new ChapterNotFoundException("Chapter {$command->chapterNumber} not found for manga {$command->mangaId}"); throw new ChapterNotFoundException("Chapter {$command->chapterNumber} not found for manga {$command->mangaId}");
} }
// 4. Save the CBZ file to storage using the path manager // 4. Extract CBZ into individual images storage
$cbzPath = $this->saveCbzFile($command, $manga, $existingChapter); $pagesDirectory = $this->imageStorage->extractFromCbz(
$existingChapter->getId(),
$command->fileBinary
);
$pageCount = $this->imageStorage->countCbzImages($command->fileBinary);
// 5. Update existing chapter with new path through the aggregate // 5. Update existing chapter with new path through the aggregate
$manga->updateChapterPages($existingChapter, $cbzPath, $existingChapter->getPageCount()); $manga->updateChapterPages($existingChapter, $pagesDirectory, $pageCount);
$this->mangaRepository->save($manga); $this->mangaRepository->save($manga);
} }
@@ -53,21 +57,4 @@ readonly class ImportChapterHandler
return strpos($fileBinary, $zipMagicNumber) === 0; return strpos($fileBinary, $zipMagicNumber) === 0;
} }
private function saveCbzFile(ImportChapter $command, \App\Domain\Manga\Domain\Model\Manga $manga, \App\Domain\Manga\Domain\Model\Chapter $chapter): string
{
$volumeNumber = $chapter->getVolume() ?? 0;
$cbzPath = $this->pathManager->buildChapterCbzPath(
$manga->getTitle()->getValue(),
(string)$manga->getPublicationYear(),
$volumeNumber,
(string)$command->chapterNumber
);
if (!file_put_contents($cbzPath, $command->fileBinary)) {
throw new \RuntimeException('Failed to save CBZ file');
}
return $cbzPath;
}
} }

View File

@@ -5,13 +5,13 @@ namespace App\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\ImportVolume; use App\Domain\Manga\Application\Command\ImportVolume;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface; use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException; use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface; use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
readonly class ImportVolumeHandler readonly class ImportVolumeHandler
{ {
public function __construct( public function __construct(
private MangaRepositoryInterface $mangaRepository, private MangaRepositoryInterface $mangaRepository,
private MangaPathManagerInterface $pathManager private ImageStorageInterface $imageStorage
) { ) {
} }
@@ -40,12 +40,14 @@ readonly class ImportVolumeHandler
); );
} }
// 4. Save the CBZ file to storage using the path manager // 4. Extract CBZ into individual images storage (shared directory for all volume chapters)
$cbzPath = $this->saveCbzFile($command, $manga); $volumeDirectoryId = sprintf('volume_%s_%d', $command->mangaId, $command->volumeNumber);
$pagesDirectory = $this->imageStorage->extractFromCbz($volumeDirectoryId, $command->fileBinary);
$pageCount = $this->imageStorage->countCbzImages($command->fileBinary);
// 5. Update all chapters with the volume path through the aggregate // 5. Update all chapters with the volume path through the aggregate
foreach ($chapters as $chapter) { foreach ($chapters as $chapter) {
$manga->updateChapterPages($chapter, $cbzPath, $chapter->getPageCount()); $manga->updateChapterPages($chapter, $pagesDirectory, $pageCount);
} }
$this->mangaRepository->save($manga); $this->mangaRepository->save($manga);
} }
@@ -56,19 +58,4 @@ readonly class ImportVolumeHandler
return strpos($fileBinary, $zipMagicNumber) === 0; return strpos($fileBinary, $zipMagicNumber) === 0;
} }
private function saveCbzFile(ImportVolume $command, \App\Domain\Manga\Domain\Model\Manga $manga): string
{
$cbzPath = $this->pathManager->buildVolumeCbzPath(
$manga->getTitle()->getValue(),
(string)$manga->getPublicationYear(),
$command->volumeNumber
);
if (!file_put_contents($cbzPath, $command->fileBinary)) {
throw new \RuntimeException('Failed to save CBZ file');
}
return $cbzPath;
}
} }

View File

@@ -154,7 +154,7 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
$pages[] = new Page( $pages[] = new Page(
basename($files[$i]), basename($files[$i]),
new PageNumber($i + 1), new PageNumber($i + 1),
sprintf('/images/pages/%s/%s', $chapterId->getValue(), basename($files[$i])), sprintf('/images/pages/%s/%s', basename($pagesDirectory), basename($files[$i])),
$imageSize[0], $imageSize[0],
$imageSize[1] $imageSize[1]
); );

View File

@@ -6,7 +6,7 @@ use App\Domain\Scraping\Application\Command\ScrapeChapter;
use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface; use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Scraping\Domain\Contract\Repository\MangaRepositoryInterface; use App\Domain\Scraping\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Scraping\Domain\Contract\Repository\SourceRepositoryInterface; use App\Domain\Scraping\Domain\Contract\Repository\SourceRepositoryInterface;
use App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface; use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
use App\Domain\Scraping\Domain\Contract\Service\ImageDownloaderInterface; use App\Domain\Scraping\Domain\Contract\Service\ImageDownloaderInterface;
use App\Domain\Scraping\Domain\Contract\Service\ScraperFactoryInterface; use App\Domain\Scraping\Domain\Contract\Service\ScraperFactoryInterface;
use App\Domain\Shared\Domain\Event\ChapterScraped; use App\Domain\Shared\Domain\Event\ChapterScraped;

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Domain\Scraping\Domain\Contract\Service;
interface ImageStorageInterface
{
/**
* Copies images to permanent storage. Returns the pagesDirectory path.
*
* @param string $chapterId The chapter UUID used as directory name
* @param string[] $localImagePaths Paths to the locally downloaded image files
*
* @return string Absolute path to the directory where images were stored
*/
public function storeChapterImages(string $chapterId, array $localImagePaths): string;
}

View File

@@ -1,31 +0,0 @@
<?php
namespace App\Domain\Scraping\Infrastructure\Service;
use App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface;
readonly class LocalImageStorage implements ImageStorageInterface
{
public function __construct(private string $storagePath)
{
}
public function storeChapterImages(string $chapterId, array $localImagePaths): string
{
$targetDir = $this->storagePath . '/pages/' . $chapterId;
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}
sort($localImagePaths);
foreach ($localImagePaths as $index => $localPath) {
$extension = pathinfo($localPath, PATHINFO_EXTENSION) ?: 'jpg';
$targetFile = sprintf('%s/%03d.%s', $targetDir, $index + 1, $extension);
copy($localPath, $targetFile);
}
return $targetDir;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Domain\Shared\Domain\Contract;
interface ImageStorageInterface
{
/**
* Store images from local file paths into the individual images storage.
* Used by the scraping flow.
*
* @param string[] $localImagePaths
* @return string The directory path where images are stored (pagesDirectory)
*/
public function storeChapterImages(string $targetId, array $localImagePaths): string;
/**
* Extract images from a CBZ binary into the individual images storage.
* Used by the import flow.
*
* @return string The directory path where images are stored (pagesDirectory)
*/
public function extractFromCbz(string $targetId, string $cbzBinary): string;
/**
* Count images in a CBZ binary.
*/
public function countCbzImages(string $cbzBinary): int;
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Domain\Shared\Infrastructure\Service;
use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
use ZipArchive;
class ImageStorageManager implements ImageStorageInterface
{
public function __construct(private string $storagePath)
{
}
public function storeChapterImages(string $targetId, array $localImagePaths): string
{
$targetDir = $this->storagePath . '/pages/' . $targetId;
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}
sort($localImagePaths);
foreach ($localImagePaths as $index => $localPath) {
$extension = pathinfo($localPath, PATHINFO_EXTENSION) ?: 'jpg';
$targetFile = sprintf('%s/%03d.%s', $targetDir, $index + 1, $extension);
copy($localPath, $targetFile);
}
return $targetDir;
}
public function extractFromCbz(string $targetId, string $cbzBinary): string
{
$targetDir = $this->storagePath . '/pages/' . $targetId;
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}
$tmpFile = tempnam(sys_get_temp_dir(), 'cbz_');
file_put_contents($tmpFile, $cbzBinary);
$zip = new ZipArchive();
if ($zip->open($tmpFile) !== true) {
unlink($tmpFile);
throw new \RuntimeException('Failed to open CBZ file as ZIP archive');
}
$imageEntries = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if (preg_match('/\.(jpg|jpeg|png|webp|gif)$/i', $name)) {
$imageEntries[] = ['index' => $i, 'name' => $name];
}
}
usort($imageEntries, fn ($a, $b) => strcmp($a['name'], $b['name']));
foreach ($imageEntries as $seq => $entry) {
$extension = strtolower(pathinfo($entry['name'], PATHINFO_EXTENSION)) ?: 'jpg';
$targetFile = sprintf('%s/%03d.%s', $targetDir, $seq + 1, $extension);
$content = $zip->getFromIndex($entry['index']);
file_put_contents($targetFile, $content);
}
$zip->close();
unlink($tmpFile);
return $targetDir;
}
public function countCbzImages(string $cbzBinary): int
{
$tmpFile = tempnam(sys_get_temp_dir(), 'cbz_');
file_put_contents($tmpFile, $cbzBinary);
$zip = new ZipArchive();
if ($zip->open($tmpFile) !== true) {
unlink($tmpFile);
throw new \RuntimeException('Failed to open CBZ file as ZIP archive');
}
$count = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if (preg_match('/\.(jpg|jpeg|png|webp|gif)$/i', $name)) {
$count++;
}
}
$zip->close();
unlink($tmpFile);
return $count;
}
}

View File

@@ -13,22 +13,22 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug; use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle; use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository; use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
use App\Tests\Domain\Manga\Adapter\InMemoryPathManager; use App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
class ImportChapterHandlerTest extends TestCase class ImportChapterHandlerTest extends TestCase
{ {
private InMemoryMangaRepository $mangaRepository; private InMemoryMangaRepository $mangaRepository;
private InMemoryPathManager $pathManager; private InMemoryImageStorage $imageStorage;
private ImportChapterHandler $handler; private ImportChapterHandler $handler;
protected function setUp(): void protected function setUp(): void
{ {
$this->mangaRepository = new InMemoryMangaRepository(); $this->mangaRepository = new InMemoryMangaRepository();
$this->pathManager = new InMemoryPathManager(); $this->imageStorage = new InMemoryImageStorage();
$this->handler = new ImportChapterHandler( $this->handler = new ImportChapterHandler(
$this->mangaRepository, $this->mangaRepository,
$this->pathManager $this->imageStorage
); );
} }

View File

@@ -12,22 +12,22 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug; use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle; use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository; use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
use App\Tests\Domain\Manga\Adapter\InMemoryPathManager; use App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
class ImportVolumeHandlerTest extends TestCase class ImportVolumeHandlerTest extends TestCase
{ {
private InMemoryMangaRepository $mangaRepository; private InMemoryMangaRepository $mangaRepository;
private InMemoryPathManager $pathManager; private InMemoryImageStorage $imageStorage;
private ImportVolumeHandler $handler; private ImportVolumeHandler $handler;
protected function setUp(): void protected function setUp(): void
{ {
$this->mangaRepository = new InMemoryMangaRepository(); $this->mangaRepository = new InMemoryMangaRepository();
$this->pathManager = new InMemoryPathManager(); $this->imageStorage = new InMemoryImageStorage();
$this->handler = new ImportVolumeHandler( $this->handler = new ImportVolumeHandler(
$this->mangaRepository, $this->mangaRepository,
$this->pathManager $this->imageStorage
); );
} }

View File

@@ -2,18 +2,31 @@
namespace App\Tests\Domain\Scraping\Adapter; namespace App\Tests\Domain\Scraping\Adapter;
use App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface; use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
class InMemoryImageStorage implements ImageStorageInterface class InMemoryImageStorage implements ImageStorageInterface
{ {
/** @var array<string, string> chapterId => pagesDirectory */ /** @var array<string, string> targetId => pagesDirectory */
public array $stored = []; public array $stored = [];
public function storeChapterImages(string $chapterId, array $localImagePaths): string public function storeChapterImages(string $targetId, array $localImagePaths): string
{ {
$dir = '/fake/pages/' . $chapterId; $dir = '/fake/pages/' . $targetId;
$this->stored[$chapterId] = $dir; $this->stored[$targetId] = $dir;
return $dir; return $dir;
} }
public function extractFromCbz(string $targetId, string $cbzBinary): string
{
$dir = '/fake/pages/' . $targetId;
$this->stored[$targetId] = $dir;
return $dir;
}
public function countCbzImages(string $cbzBinary): int
{
return 0;
}
} }