Skip to content

Commit

Permalink
feat(kobo): Convert ebooks using kepubify on the fly
Browse files Browse the repository at this point in the history
  • Loading branch information
ragusa87 committed Nov 26, 2024
1 parent fcfbac1 commit 50f9f38
Show file tree
Hide file tree
Showing 12 changed files with 363 additions and 30 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: 1 addition & 1 deletion .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ TYPESENSE_KEY=xyz

# Disable the proxy for the test env
KOBO_PROXY_USE_EVERYWHERE=0
KOBO_PROXY_ENABLED=0
KOBO_PROXY_ENABLED=0
26 changes: 25 additions & 1 deletion .github/workflows/symfony.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
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
4 changes: 2 additions & 2 deletions src/Controller/Kobo/KoboDownloadController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions src/Kobo/BookDownloadInfo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace App\Kobo;

class BookDownloadInfo
{
public function __construct(private int $size, private string $url)
{
}

public function getSize(): int
{
return $this->size;
}

public function getUrl(): string
{
return $this->url;
}
}
84 changes: 65 additions & 19 deletions src/Kobo/DownloadHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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);
}

Expand Down Expand Up @@ -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;
}
Expand All @@ -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);
}
}
37 changes: 37 additions & 0 deletions src/Kobo/Kepubify/KepubifyEnabler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace App\Kobo\Kepubify;

use Symfony\Component\DependencyInjection\Attribute\Autowire;

class KepubifyEnabler
{
public function __construct(
#[Autowire(param: 'KEPUBIFY_BIN')]
private string $kepubifyBinary,
) {
}

public function setKepubifyBinary(string $kepubifyBinary): void
{
$this->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;
}
}
21 changes: 21 additions & 0 deletions src/Kobo/Kepubify/KepubifyMessage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace App\Kobo\Kepubify;

/**
* 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,
) {
}
}
Loading

0 comments on commit 50f9f38

Please sign in to comment.