Skip to content

Commit

Permalink
Convert ebooks using kepubify on the fly
Browse files Browse the repository at this point in the history
  • Loading branch information
ragusa87 committed Aug 18, 2024
1 parent 5ad1ccd commit 417a234
Show file tree
Hide file tree
Showing 6 changed files with 127 additions and 11 deletions.
2 changes: 1 addition & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -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
###< kobo/proxy
2 changes: 2 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
37 changes: 28 additions & 9 deletions src/Kobo/DownloadHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@
use App\Entity\KoboDevice;
use App\Exception\BookFileNotFound;
use App\Kobo\ImageProcessor\CoverTransformer;
use App\Kobo\Messenger\KepubifyMessage;
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
Expand All @@ -19,8 +23,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
Expand Down Expand Up @@ -86,15 +91,20 @@ public function getCoverResponse(Book $book, int $width, int $height, bool $gray
return $response;
}

public function getResponse(Book $book): StreamedResponse
public function getResponse(Book $book): 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);

// Convert the file to kepub (Kobo's epub format) as a temporary file
$temporaryFile = $this->runKepubify($bookPath);
$fileToStream = $temporaryFile ?? $bookPath;
$fileSize = filesize($fileToStream);

$response = (new BinaryFileResponse($fileToStream, Response::HTTP_OK))
->deleteFileAfterSend($temporaryFile !== null);

$filename = $book->getBookFilename();
$encodedFilename = rawurlencode($filename);
Expand All @@ -104,10 +114,11 @@ public function getResponse(Book $book): StreamedResponse
'epub', 'epub3' => '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;
}
Expand All @@ -116,4 +127,12 @@ 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;
}
}
21 changes: 21 additions & 0 deletions src/Kobo/Messenger/KepubifyMessage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace App\Kobo\Messenger;

/**
* Convert an ebook with kepubify binary
* The destination property will be set to the path of the converted file,
*/
class KepubifyMessage
{
/**
* @var string|null Path of the destination file (which aim to be a temporary file). Null if the conversion failed.
*/
public ?string $destination = null;

public function __construct(
/** Path of the source ebook to convert */
public string $source
) {
}
}
74 changes: 74 additions & 0 deletions src/Kobo/Messenger/KepubifyMessageHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

namespace App\Kobo\Messenger;

use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Process\Process;

#[AsMessageHandler]
class KepubifyMessageHandler
{
public const CACHE_FOLDER = '/kepubify';
public const TEMP_NAME_SUFFIX = 'kepub-';

public function __construct(
#[Autowire(param: 'kernel.cache_dir')]
private readonly string $cacheDir,
private readonly LoggerInterface $logger,
#[Autowire(param: 'KEPUBIFY_BIN')]
private string $kepubifyBinary
) {
}

public function __invoke(KepubifyMessage $message): void
{
// Disable kepubify if the path is not set
if (trim($this->kepubifyBinary) === '') {
return;
}

// Create a temporary file
$temporaryFile = $this->getTemporaryFilename();
if ($temporaryFile === false) {
$this->logger->error('Error while creating temporary file');

return;
}

// Run the conversion
$process = new Process([$this->kepubifyBinary, '--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);

return;
}

$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);
}
}
2 changes: 1 addition & 1 deletion tests/Controller/Kobo/KoboDownloadControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public function testDownload(): void
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.epub; filename*=utf-8''TheOdysses.epub";
self::assertResponseHeaderSame('Content-Disposition', $expectedDisposition, 'The Content-Disposition header is not as expected');

}
Expand Down

0 comments on commit 417a234

Please sign in to comment.