Skip to content

Commit

Permalink
Merge pull request #166 from ragusa87/convert-epub
Browse files Browse the repository at this point in the history
 Transform ebook on the fly (while syncing) using kebubify and synchronous messengers
  • Loading branch information
SergioMendolia authored Nov 26, 2024
2 parents ae96a6a + 41a3507 commit 4ab99a2
Show file tree
Hide file tree
Showing 20 changed files with 581 additions and 40 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
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"symfony/http-client": "^6.2",
"symfony/intl": "^7.1",
"symfony/mailer": "^7.1",
"symfony/messenger": "^6.2",
"symfony/mime": "^7.1",
"symfony/monolog-bundle": "^3.0",
"symfony/notifier": "^7.1",
Expand Down
2 changes: 1 addition & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions config/packages/framework.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ framework:
enable_attributes: true
name_converter: 'App\Kobo\Serializer\KoboNameConverter'

cache:
pools:
kepubify_result_pool:
adapter: cache.app

when@test:
framework:
test: true
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;
}
}
99 changes: 80 additions & 19 deletions src/Kobo/DownloadHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@
use App\Entity\KoboDevice;
use App\Exception\BookFileNotFound;
use App\Kobo\ImageProcessor\CoverTransformer;
use App\Kobo\Kepubify\KepubifyConversionFailed;
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 +25,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 +40,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 +87,45 @@ 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();
$message = null;

if ($format === MetadataResponseService::KEPUB_FORMAT) {
try {
$message = $this->runKepubify($bookPath);
} catch (KepubifyConversionFailed $e) {
throw new NotFoundHttpException('Book conversion failed', $e);
}
}

$fileToStream = $message?->destination ?? $bookPath;
$fileSize = $message?->size ?? filesize($fileToStream);

$response = (new BinaryFileResponse($fileToStream, Response::HTTP_OK))
->deleteFileAfterSend($message?->destination !== 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 +134,47 @@ public function coverExist(Book $book): bool
{
return $this->fileSystemManager->coverExist($book);
}

/**
* @throws KepubifyConversionFailed
*/
private function runKepubify(string $bookPath): KepubifyMessage
{
$message = new KepubifyMessage($bookPath);
$this->messageBus->dispatch($message);

if ($message->destination === null || $message->size === null) {
throw new KepubifyConversionFailed($bookPath);
}

return $message;
}

/**
* @throws KepubifyConversionFailed
* @throws BookFileNotFound
*/
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) {
return new BookDownloadInfo($this->getSize($book), $url);
}

// Convert the book to fetch the final size
$message = $this->runKepubify($bookPath);

$info = new BookDownloadInfo((int) $message->size, $url);
if ($message->destination !== null) {
unlink($message->destination);
}

return $info;
}
}
33 changes: 33 additions & 0 deletions src/Kobo/Kepubify/KebpubifyCachedData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace App\Kobo\Kepubify;

class KebpubifyCachedData implements \JsonSerializable
{
private string $content;
private int $size;

public function __construct(string $filename)
{
$this->size = (int) filesize($filename);
$this->content = (string) file_get_contents($filename);
}

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

public function getContent(): string
{
return $this->content;
}

public function jsonSerialize(): array
{
return [
'size' => $this->size,
'content' => $this->content,
];
}
}
11 changes: 11 additions & 0 deletions src/Kobo/Kepubify/KepubifyConversionFailed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace App\Kobo\Kepubify;

class KepubifyConversionFailed extends \RuntimeException
{
public function __construct(string $originalPath, ?\Throwable $previous = null)
{
parent::__construct(sprintf('Conversion failed for book %s', $originalPath), 0, $previous);
}
}
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;
}
}
25 changes: 25 additions & 0 deletions src/Kobo/Kepubify/KepubifyMessage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?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;
/**
* @var int|null File size of the destination file
*/
public ?int $size = null;

public function __construct(
/** Path of the source ebook to convert */
public string $source,
) {
}
}
Loading

0 comments on commit 4ab99a2

Please sign in to comment.