diff --git a/.env b/.env index b20bbb2c..54dbb535 100644 --- a/.env +++ b/.env @@ -50,4 +50,4 @@ BOOK_FILE_NAMING_FORMAT="{serie}-{serieIndex}-{title}" KOBO_PROXY_USE_DEV=0 KOBO_PROXY_USE_EVERYWHERE=0 KOBO_PROXY_ENABLED=1 -###< kobo/proxy \ No newline at end of file +###< kobo/proxy diff --git a/.env.test b/.env.test index 16c52ced..d2ff4f8b 100644 --- a/.env.test +++ b/.env.test @@ -13,4 +13,4 @@ TYPESENSE_KEY=xyz # Disable the proxy for the test env KOBO_PROXY_USE_EVERYWHERE=0 -KOBO_PROXY_ENABLED=0 \ No newline at end of file +KOBO_PROXY_ENABLED=0 diff --git a/.github/workflows/symfony.yml b/.github/workflows/symfony.yml index d38b77b4..e0cc84e5 100644 --- a/.github/workflows/symfony.yml +++ b/.github/workflows/symfony.yml @@ -31,7 +31,9 @@ jobs: php-version: '8.2' - uses: actions/checkout@v3 - name: Copy .env.test.local - run: php -r "file_exists('.env.test.local') || copy('.env.test', '.env.test.local');" + run: | + php -r "file_exists('.env.test.local') || copy('.env.test', '.env.test.local');" + echo "KEPUBIFY_BIN=/usr/local/bin/kepubify" >> .env.test.local - name: Cache Composer packages id: composer-cache uses: actions/cache@v3 @@ -42,6 +44,28 @@ jobs: ${{ runner.os }}-php- - name: Install Dependencies run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist + - name: Cache kepubify + id: kepubify-cache-restore + uses: actions/cache@v3 + with: + path: /usr/local/bin/kepubify + key: ${{ runner.os }}-kepubify-cache + - name: Install kepubify + if: steps.kepubify-cache-restore.outputs.cache-hit != 'true' + run: | + mkdir -p /usr/local/bin/ + # Install kepubify (from https://github.com/linuxserver/docker-calibre-web/blob/master/Dockerfile) + KEPUBIFY_RELEASE=$(curl -sX GET "https://api.github.com/repos/pgaskin/kepubify/releases/latest" | awk '/tag_name/{print $4;exit}' FS='[""]'); + curl -o /usr/local/bin/kepubify -L https://github.com/pgaskin/kepubify/releases/download/${KEPUBIFY_RELEASE}/kepubify-linux-64bit + chmod +x /usr/local/bin/kepubify + - name: Cache kepubify + uses: actions/cache/save@v4 + if: steps.kepubify-cache-restore.outputs.cache-hit != 'true' + id: kepubify-cache-save + with: + path: /usr/local/bin/kepubify + key: ${{ runner.os }}-kepubify-cache + - name: Create Database run: | mkdir -p data diff --git a/config/services.yaml b/config/services.yaml index e22f5b7f..5a58c8d4 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -14,6 +14,8 @@ parameters: KOBO_API_URL: '%env(KOBO_API_URL)%' env(KOBO_IMAGE_API_URL): "https://cdn.kobo.com/book-images" KOBO_IMAGE_API_URL: '%env(KOBO_IMAGE_API_URL)%' + env(KEPUBIFY_BIN): "/usr/bin/kepubify" + KEPUBIFY_BIN: '%env(KEPUBIFY_BIN)%' services: # default configuration for services in *this* file _defaults: diff --git a/src/Controller/Kobo/KoboDownloadController.php b/src/Controller/Kobo/KoboDownloadController.php index 0fb4e719..db56ac10 100644 --- a/src/Controller/Kobo/KoboDownloadController.php +++ b/src/Controller/Kobo/KoboDownloadController.php @@ -25,11 +25,11 @@ public function __construct( } #[Route('/v1/download/{id}.{extension}', name: 'download', requirements: ['bookId' => '\d+', 'extension' => '[A-Za-z0-9]+'], methods: ['GET'])] - public function download(KoboDevice $kobo, Book $book): Response + public function download(KoboDevice $kobo, Book $book, string $extension): Response { $this->assertCanDownload($kobo, $book); - return $this->downloadHelper->getResponse($book); + return $this->downloadHelper->getResponse($book, $extension); } private function assertCanDownload(KoboDevice $kobo, Book $book): void diff --git a/src/Kobo/BookDownloadInfo.php b/src/Kobo/BookDownloadInfo.php new file mode 100644 index 00000000..af6eedab --- /dev/null +++ b/src/Kobo/BookDownloadInfo.php @@ -0,0 +1,20 @@ +size; + } + + public function getUrl(): string + { + return $this->url; + } +} diff --git a/src/Kobo/DownloadHelper.php b/src/Kobo/DownloadHelper.php index ec28902c..3694d14a 100644 --- a/src/Kobo/DownloadHelper.php +++ b/src/Kobo/DownloadHelper.php @@ -6,12 +6,16 @@ use App\Entity\KoboDevice; use App\Exception\BookFileNotFound; use App\Kobo\ImageProcessor\CoverTransformer; +use App\Kobo\Kepubify\KepubifyMessage; +use App\Kobo\Response\MetadataResponseService; use App\Service\BookFileSystemManager; use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; class DownloadHelper @@ -20,8 +24,9 @@ public function __construct( private readonly BookFileSystemManager $fileSystemManager, private readonly CoverTransformer $coverTransformer, protected UrlGeneratorInterface $urlGenerator, - protected LoggerInterface $logger) - { + protected LoggerInterface $logger, + protected MessageBusInterface $messageBus, + ) { } protected function getBookFilename(Book $book): string @@ -34,17 +39,12 @@ public function getSize(Book $book): int return $this->fileSystemManager->getBookSize($book) ?? 0; } - public function getCoverSize(Book $book): int - { - return $this->fileSystemManager->getCoverSize($book) ?? 0; - } - - public function getUrlForKoboDevice(Book $book, KoboDevice $kobo): string + private function getUrlForKoboDevice(Book $book, KoboDevice $kobo, string $extension): string { return $this->urlGenerator->generate('app_kobodownload', [ 'id' => $book->getId(), 'accessKey' => $kobo->getAccessKey(), - 'extension' => $book->getExtension(), + 'extension' => strtolower($extension), ], UrlGeneratorInterface::ABSOLUTE_URL); } @@ -86,28 +86,44 @@ public function getCoverResponse(Book $book, int $width, int $height, string $ex return $response; } - public function getResponse(Book $book): StreamedResponse + /** + * @throws NotFoundHttpException Book conversion failed + */ + public function getResponse(Book $book, string $format): Response { $bookPath = $this->getBookFilename($book); if (false === $this->exists($book)) { throw new BookFileNotFound($bookPath); } - $response = new StreamedResponse(function () use ($bookPath) { - readfile($bookPath); - }, Response::HTTP_OK); - $filename = $book->getBookFilename(); + $temporaryFile = null; + + if ($format === MetadataResponseService::KEPUB_FORMAT) { + $temporaryFile = $this->runKepubify($bookPath); + if ($temporaryFile === null) { + throw new NotFoundHttpException('The conversion to KEPUB failed'); + } + } + + $fileToStream = $temporaryFile ?? $bookPath; + $fileSize = filesize($fileToStream); + + $response = (new BinaryFileResponse($fileToStream, Response::HTTP_OK)) + ->deleteFileAfterSend($temporaryFile !== null); + + $filename = basename($book->getBookFilename(), $book->getExtension()).strtolower($format); $encodedFilename = rawurlencode($filename); $simpleName = rawurlencode(sprintf('book-%s-%s', $book->getId(), preg_replace('/[^a-zA-Z0-9\.\-_]/', '_', $filename))); - $response->headers->set('Content-Type', match (strtolower($book->getExtension())) { - 'epub', 'epub3' => 'application/epub+zip', + $response->headers->set('Content-Type', match (strtoupper($format)) { + MetadataResponseService::KEPUB_FORMAT, MetadataResponseService::EPUB_FORMAT, MetadataResponseService::EPUB3_FORMAT => 'application/epub+zip', default => 'application/octet-stream', }); - $response->headers->set('Content-Disposition', - sprintf('attachment; filename="%s"; filename*=UTF-8\'\'%s', $simpleName, $encodedFilename)); + $response->headers->set('Content-Disposition', HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $encodedFilename, $simpleName)); - $response->headers->set('Content-Length', (string) $this->getSize($book)); + if ($fileSize !== false) { + $response->headers->set('Content-Length', (string) $fileSize); + } return $response; } @@ -116,4 +132,34 @@ public function coverExist(Book $book): bool { return $this->fileSystemManager->coverExist($book); } + + private function runKepubify(string $bookPath): ?string + { + $conversionDto = new KepubifyMessage($bookPath); + $this->messageBus->dispatch($conversionDto); + + return $conversionDto->destination; + } + + public function getDownloadInfo(Book $book, KoboDevice $koboDevice, string $extension): BookDownloadInfo + { + $bookPath = $this->getBookFilename($book); + if (false === $this->exists($book)) { + throw new BookFileNotFound($bookPath); + } + + $url = $this->getUrlForKoboDevice($book, $koboDevice, $extension); + + if (strtoupper($extension) === MetadataResponseService::KEPUB_FORMAT) { + $temporaryFile = $this->runKepubify($bookPath); + if ($temporaryFile !== null) { + $info = new BookDownloadInfo((int) filesize($temporaryFile), $url); + unlink($temporaryFile); + + return $info; + } + } + + return new BookDownloadInfo($this->getSize($book), $url); + } } diff --git a/src/Kobo/Kepubify/KepubifyEnabler.php b/src/Kobo/Kepubify/KepubifyEnabler.php new file mode 100644 index 00000000..727e5954 --- /dev/null +++ b/src/Kobo/Kepubify/KepubifyEnabler.php @@ -0,0 +1,37 @@ +kepubifyBinary = $kepubifyBinary; + } + + public function isEnabled(): bool + { + return trim($this->kepubifyBinary) !== ''; + } + + public function getKepubifyBinary(): string + { + return $this->kepubifyBinary; + } + + public function disable(): string + { + $lastValue = $this->kepubifyBinary; + $this->kepubifyBinary = ''; + + return $lastValue; + } +} diff --git a/src/Kobo/Kepubify/KepubifyMessage.php b/src/Kobo/Kepubify/KepubifyMessage.php new file mode 100644 index 00000000..79fe1e80 --- /dev/null +++ b/src/Kobo/Kepubify/KepubifyMessage.php @@ -0,0 +1,21 @@ +kepubifyEnabler->isEnabled()) { + return; + } + + // Create a temporary file + $temporaryFile = $this->getTemporaryFilename(); + if ($temporaryFile === false) { + $this->logger->error('Error while creating temporary file'); + + return; + } + + // Fetch the conversion result from cache + try { + $item = $this->cachePool->getItem('kepubify_'.md5($message->source)); + } catch (InvalidArgumentException $e) { + $this->logger->error('Error while caching kepubify: {error}', [ + 'error' => $e->getMessage(), + 'exception' => $e, + ]); + + return; + } + + // Do the conversion and cache it + if (false === $item->isHit()) { + $temporaryFile = $this->convert($message, $temporaryFile); + if ($temporaryFile !== null) { + $item->set(file_get_contents($temporaryFile)); + $item->expiresAfter(self::CACHE_TIME_SECONDS); + $this->cachePool->save($item); + } + + $message->destination = $temporaryFile; + + return; + } + + // Restore the result from cache + file_put_contents($temporaryFile, $item->get()); + + $message->destination = $temporaryFile; + } + + /** + * Create a temporary file to handle the conversion result. + * Note that the name must be unique to handle concurrent requests + * because the file will be deleted once the request is served. + * + * @return string|false + */ + private function getTemporaryFilename(): string|false + { + $dir = $this->cacheDir.self::CACHE_FOLDER; + if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) { + return false; + } + + return tempnam($dir, self::TEMP_NAME_SUFFIX); + } + + private function convert(KepubifyMessage $message, string $temporaryFile): ?string + { + // Run the conversion + $process = new Process([$this->kepubifyEnabler->getKepubifyBinary(), '--output', $temporaryFile, $message->source]); + $this->logger->debug('Run kepubify command: {command}', ['command' => $process->getCommandLine()]); + $process->run(); + + if (!$process->isSuccessful()) { + $this->logger->error('Error while running kepubify: {output}: {error}', [ + 'output' => $process->getOutput(), + 'error' => $process->getErrorOutput(), + ]); + @unlink($temporaryFile); + $temporaryFile = null; + } + + return $temporaryFile; + } +} diff --git a/src/Kobo/Response/MetadataResponseService.php b/src/Kobo/Response/MetadataResponseService.php index 9f6d2e15..3dc6e915 100644 --- a/src/Kobo/Response/MetadataResponseService.php +++ b/src/Kobo/Response/MetadataResponseService.php @@ -5,11 +5,18 @@ use App\Entity\Book; use App\Entity\KoboDevice; use App\Kobo\DownloadHelper; +use App\Kobo\Kepubify\KepubifyEnabler; use App\Kobo\SyncToken; class MetadataResponseService { - public function __construct(protected DownloadHelper $downloadHelper) + public const KEPUB_FORMAT = 'KEPUB'; + public const EPUB3_FORMAT = 'EPUB3'; + public const EPUB_FORMAT = 'EPUB'; + + public function __construct( + protected DownloadHelper $downloadHelper, + protected KepubifyEnabler $kepubifyEnabler) { } @@ -21,12 +28,19 @@ protected function getDownloadUrls(Book $book, KoboDevice $kobo, ?array $filters $response = []; - $formats = ['EPUB3']; // EPUB3 is required for Kobo + // For KeyPub, we remove the EPUB3 format and replace it + $formats = [self::EPUB3_FORMAT]; + if ($this->kepubifyEnabler->isEnabled()) { + $formats = [self::KEPUB_FORMAT]; + } + foreach ($formats as $format) { // and ... EPUB3FL ?; + $extension = $format === self::KEPUB_FORMAT ? self::KEPUB_FORMAT : $book->getExtension(); + $downloadInfo = $this->downloadHelper->getDownloadInfo($book, $kobo, $extension); $response[] = [ 'Format' => $format, - 'Size' => $this->downloadHelper->getSize($book), - 'Url' => $this->downloadHelper->getUrlForKoboDevice($book, $kobo), + 'Size' => $downloadInfo->getSize(), + 'Url' => $downloadInfo->getUrl(), 'Platform' => $platform, ]; } diff --git a/tests/Controller/Kobo/KoboDownloadControllerTest.php b/tests/Controller/Kobo/KoboDownloadControllerTest.php index a337aecf..f4534360 100644 --- a/tests/Controller/Kobo/KoboDownloadControllerTest.php +++ b/tests/Controller/Kobo/KoboDownloadControllerTest.php @@ -6,6 +6,8 @@ use App\Entity\Book; use App\Entity\KoboDevice; use App\Kobo\DownloadHelper; +use App\Kobo\Kepubify\KepubifyEnabler; +use App\Kobo\Response\MetadataResponseService; class KoboDownloadControllerTest extends AbstractKoboControllerTest { @@ -14,6 +16,55 @@ public function testDownload(): void $client = static::getClient(); $this->injectFakeFileSystemManager(); + $book = $this->findByIdAndKobo(BookFixture::ID, $this->getKoboDevice()); + self::assertNotNull($book, 'The book is not linked to the Kobo'); + + /** @var DownloadHelper $downloadHelper */ + $downloadHelper = self::getContainer()->get(DownloadHelper::class); + + self::assertTrue($downloadHelper->exists($book), 'The book file does not exist'); + + $client?->request('GET', sprintf('/kobo/%s/v1/download/%s.%s', $this->accessKey, BookFixture::ID, 'epub')); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('Content-Type', 'application/epub+zip'); + self::assertResponseHasHeader('Content-Length'); + $expectedDisposition = "attachment; filename=book-1-TheOdysses.epub; filename*=utf-8''TheOdysses.epub"; + self::assertResponseHeaderSame('Content-Disposition', $expectedDisposition, 'The Content-Disposition header is not as expected'); + + } + + public function testDownloadKepubFailed(): void + { + $client = static::getClient(); + $this->injectFakeFileSystemManager(); + + // Disable Kepubify conversion + $lastValue = $this->getKepubifyEnabler()->disable(); + + try { + $book = $this->findByIdAndKobo(BookFixture::ID, $this->getKoboDevice()); + self::assertNotNull($book, 'The book is not linked to the Kobo'); + + /** @var DownloadHelper $downloadHelper */ + $downloadHelper = self::getContainer()->get(DownloadHelper::class); + + self::assertTrue($downloadHelper->exists($book), 'The book file does not exist'); + + + $client?->request('GET', sprintf('/kobo/%s/v1/download/%s.%s', $this->accessKey, BookFixture::ID, MetadataResponseService::KEPUB_FORMAT)); + self::assertResponseStatusCodeSame(404); // We can not download kepub as the conversion is disabled + } finally { + // Re-enable Kepubify conversion for other tests + $this->getKepubifyEnabler()->setKepubifyBinary($lastValue); + } + } + + public function testDownloadKepub(): void + { + $client = static::getClient(); + $this->injectFakeFileSystemManager(); + $book = $this->findByIdAndKobo(BookFixture::ID, $this->getKoboDevice()); self::assertNotNull($book, 'The book is not linked to the Kobo'); @@ -23,13 +74,13 @@ public function testDownload(): void self::assertTrue($downloadHelper->exists($book), 'The book file does not exist'); - $client?->request('GET', sprintf('/kobo/%s/v1/download/%s.epub', $this->accessKey, BookFixture::ID)); + $client?->request('GET', sprintf('/kobo/%s/v1/download/%s.'.MetadataResponseService::KEPUB_FORMAT, $this->accessKey, BookFixture::ID)); self::assertResponseIsSuccessful(); self::assertResponseHeaderSame('Content-Type', 'application/epub+zip'); self::assertResponseHasHeader('Content-Length'); - $expectedDisposition = "attachment; filename=\"book-1-TheOdysses.epub\"; filename*=UTF-8''TheOdysses.epub"; + $expectedDisposition = "attachment; filename=book-1-TheOdysses.kepub; filename*=utf-8''TheOdysses.kepub"; self::assertResponseHeaderSame('Content-Disposition', $expectedDisposition, 'The Content-Disposition header is not as expected'); } @@ -38,4 +89,12 @@ private function findByIdAndKobo(int $bookId, KoboDevice $kobo): ?Book { return $this->getEntityManager()->getRepository(Book::class)->findByIdAndKoboDevice($bookId, $kobo); } + + private function getKepubifyEnabler(): KepubifyEnabler + { + $service = self::getContainer()->get(KepubifyEnabler::class); + assert($service instanceof KepubifyEnabler); + + return $service; + } } \ No newline at end of file