Skip to content

Commit

Permalink
feat(kobo): Kobo book conversion using calibre and kepubify
Browse files Browse the repository at this point in the history
  • Loading branch information
ragusa87 committed Aug 17, 2024
1 parent fec8b90 commit 47b65e9
Show file tree
Hide file tree
Showing 13 changed files with 565 additions and 1 deletion.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
/public/alternatives/
/var/
/.composer
/vendor/
Expand Down
50 changes: 50 additions & 0 deletions calibre/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#FROM alpine:edge
#RUN apk add --no-cache \
# build-base \
# python3 \
# python3-dev
#RUN apk add calibre calibre-pyc --repository=http://dl-cdn.alpinelinux.org/alpine/edge/testing/


FROM ubuntu:oracular
RUN apt-get update && apt-get install -y \
build-essential \
libegl1 \
libopengl0 \
libxcb-cursor0 \
python-is-python3 \
python3 \
python3-dev \
python3-venv \
wget \
&& rm -rf /var/lib/apt/lists/*
RUN wget -nv -O- https://download.calibre-ebook.com/linux-installer.py | python -c "import sys; main=lambda:sys.stderr.write('Download failed\n'); exec(sys.stdin.read()); main()"

RUN apt-get update && apt-get install -y \
libxkbcommon-x11-0 \
xserver-xorg-core \
libnss3-dev \
&& rm -rf /var/lib/apt/lists/*
# Set up a virtual environment
ENV VIRTUAL_ENV=/opt/venv
RUN python3 -m venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
ENV PATH="/opt/calibre:$PATH"

# Install python modules
RUN /opt/venv/bin/pip install --upgrade pip && \
/opt/venv/bin/pip install \
flask \
flask_cors
# Copy the server script to the container
COPY --link server.py /usr/local/bin/server.py

## Install kepubify
ENV KEPUBIFY_VER="4.0.4"
RUN sh -c 'set -vx; if [ "$(uname -m)" = "x86_64" ];then kepubify_arch=64bit;elif [ "$(uname -m)" = "aarch64" ];then kepubify_arch=arm64;elif [ "$(uname -m)" = "armv7l" ];then kepubify_arch=arm; fi && wget https://github.com/pgaskin/kepubify/releases/download/v${KEPUBIFY_VER}/kepubify-linux-${kepubify_arch} -O /usr/local/bin/kepubify && chmod 755 /usr/local/bin/kepubify'

EXPOSE 7654
ENTRYPOINT ["/bin/sh"]

# Run the server
CMD ["-c", ". /opt/venv/bin/activate && exec python3 /usr/local/bin/server.py"]
98 changes: 98 additions & 0 deletions calibre/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
from flask import Flask, request, jsonify, send_file, make_response, render_template_string, redirect
from flask_cors import CORS
import os
import shutil
import subprocess
import sys
import uuid
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 4 * 1024 * 1024 * 1024 # 4GB max upload size

CORS(app)

# Static HTML form template
convert_form = '''
<!DOCTYPE html>
<html>
<head>
<title>File Conversion Form</title>
</head>
<body>
<h2>File Conversion Form</h2>
<form action="/convert" method="post" enctype="multipart/form-data">
<label for="file">Select file:</label>
<input type="file" id="file" name="file"><br><br>
<label for="input_format">Input Format:</label>
<input type="text" id="input_format" name="input_format" value="pdf"><br><br>
<label for="output_format">Output Format:</label>
<input type="text" id="output_format" name="output_format" value="epub"><br><br>
<input type="submit" value="Convert">
</form>
</body>
</html>
'''

@app.route("/", methods=['GET'])
def index():
return make_response(redirect("/convert"))

@app.route('/convert', methods=['POST', 'GET'])
def convert():
if request.method == 'GET':
# Render the HTML form with text/html content type
response = make_response(render_template_string(convert_form))
response.headers['Content-Type'] = 'text/html'
return response

data = request.files['file']
unique_prefix = str(uuid.uuid4())
input_format = request.form.get('input_format', 'pdf')
output_format = request.form.get('output_format', 'epub')
input_file = f"/tmp/pandoc/{unique_prefix}_input.{input_format}"
output_file = f"/tmp/pandoc/{unique_prefix}_output.{output_format}"
kobo_output_file = f"/tmp/pandoc/{unique_prefix}_kobo.epub"

data.save(input_file)


# Run ebook-convert conversion
try:
if output_format != input_format and not (output_format == 'kobo.epub' and input_format == 'epub'):
# Run pandoc conversion with error handling
subprocess.run(['ebook-convert', input_file, output_file], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
else:
shutil.copy(input_file, output_file)
except subprocess.CalledProcessError as e:
# Handle pandoc conversion error
os.remove(input_file)
stderr_str = e.stderr.decode('utf-8')
return jsonify({"error": "Conversion failed", "details": str(e), "stderr": stderr_str}), 500

if 'kobo' in output_format.lower():
try:
# Run pandoc conversion with error handling
subprocess.run(['kepubify', '-o', kobo_output_file, output_file ], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
os.remove(output_file)
output_file = kobo_output_file
except subprocess.CalledProcessError as e:
# Handle pandoc conversion error
os.remove(output_file)
os.remove(input_file)
stderr_str = e.stderr.decode('utf-8')
return jsonify({"error": "Kobo Conversion failed", "details": str(e), "stderr": stderr_str}), 500

response = send_file(output_file, as_attachment=True)

# Remove the temporary files after sending
os.remove(input_file)
os.remove(output_file)

return response

@app.route('/health', methods=['GET'])
def health():
return jsonify({"status": "healthy"})

if __name__ == '__main__':
port = int(sys.argv[1]) if len(sys.argv) > 1 else 7654
app.run(host='0.0.0.0', port=port)
8 changes: 8 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ services:
tags:
- { name: kernel.event_listener, event: kernel.request, method: onKernelRequest, priority: 130 }

App\Service\BookFormatStorage:
arguments:
$storageDir: '%kernel.project_dir%/public/alternatives'

App\Service\BookConverter:
bind:
$storageDir: '%kernel.project_dir%/public/alternatives'

App\EventListener\LanguageListener:
tags:
- { name: kernel.event_listener, event: kernel.request, method: onKernelRequest, priority: 101 }
Expand Down
12 changes: 11 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,17 @@ services:
- 'traefik.http.routers.npm.entrypoints=https,http'
- 'traefik.http.routers.npm.rule=Host(`biblioteca-npm.docker.test`)'
- 'traefik.http.services.npm.loadbalancer.server.port=8181'

calibre:
hostname: calibre
build:
context: ./calibre
volumes:
- ./var/cache/pandoc:/tmp/pandoc
ports:
- 7654:7654
networks:
- default
- pontsun

db:
image: mariadb:10.10
Expand Down
41 changes: 41 additions & 0 deletions migrations/Version20240716142454.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240716142454 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add bookformatss';
}

public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE book_format (id INT AUTO_INCREMENT NOT NULL, book_id INT NOT NULL, type VARCHAR(255) NOT NULL, INDEX IDX_F76D795216A2B381 (book_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE book_format ADD CONSTRAINT FK_F76D795216A2B381 FOREIGN KEY (book_id) REFERENCES book (id)');
$this->addSql('ALTER TABLE kobo_device DROP FOREIGN KEY FK_42E56EFA76ED395');
$this->addSql('DROP INDEX idx_42e56efa76ed395 ON kobo_device');
$this->addSql('CREATE INDEX IDX_2EB06A2BA76ED395 ON kobo_device (user_id)');
$this->addSql('ALTER TABLE kobo_device ADD CONSTRAINT FK_42E56EFA76ED395 FOREIGN KEY (user_id) REFERENCES user (id)');
}

public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE book_format DROP FOREIGN KEY FK_F76D795216A2B381');
$this->addSql('DROP TABLE book_format');
$this->addSql('ALTER TABLE kobo_device DROP FOREIGN KEY FK_2EB06A2BA76ED395');
$this->addSql('DROP INDEX idx_2eb06a2ba76ed395 ON kobo_device');
$this->addSql('CREATE INDEX IDX_42E56EFA76ED395 ON kobo_device (user_id)');
$this->addSql('ALTER TABLE kobo_device ADD CONSTRAINT FK_2EB06A2BA76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)');
}
}
53 changes: 53 additions & 0 deletions src/Command/BookConvertCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

namespace App\Command;

use App\Repository\BookRepository;
use App\Service\BookConverter;
use App\Service\BookFormatStorage;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(
name: 'app:book:convert',
description: 'Add a short description for your command',
)]
class BookConvertCommand extends Command
{
public function __construct(
protected BookRepository $bookRepository,
protected BookConverter $bookConverter)
{
parent::__construct();
}

protected function configure(): void
{
$this
->addArgument('bookId', InputArgument::REQUIRED, 'Book ID')
->addOption('format', null, InputOption::VALUE_REQUIRED, 'Book format')
;
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$format = $input->getOption('format');
$format = !is_string($format) || $format === '' ? BookFormatStorage::FORMAT_EPUB_KOBO : $format;
$id = $input->getArgument('bookId');
$book = $this->bookRepository->findOneBy(['id' => $id]);

if ($book === null) {
$output->writeln('Book not found');

return 1;
}

$this->bookConverter->convert($book, $format);

return Command::SUCCESS;
}
}
40 changes: 40 additions & 0 deletions src/Entity/Book.php
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,20 @@ class Book
#[ORM\OneToMany(mappedBy: 'book', targetEntity: KoboSyncedBook::class, cascade: ['remove'], orphanRemoval: true)]
private Collection $koboSyncedBooks;

/**
* @var Collection<int, BookFormat>
*/
#[ORM\OneToMany(mappedBy: 'book', targetEntity: BookFormat::class, orphanRemoval: true)]
private Collection $bookFormats;

public function __construct()
{
$this->bookInteractions = new ArrayCollection();
$this->shelves = new ArrayCollection();
$this->uuid = $this->generateUuid();
$this->koboSyncedBooks = new ArrayCollection();
$this->bookmarkUsers = new ArrayCollection();
$this->bookFormats = new ArrayCollection();
}

public function getId(): ?int
Expand Down Expand Up @@ -589,4 +596,37 @@ public function setBookmarkUsers(Collection $bookmarkUsers): self

return $this;
}

public function hasFormat(string $format): bool
{
foreach ($this->bookFormats as $bookFormat) {
if ($bookFormat->getType() === $format) {
return true;
}
}

return false;
}

public function addBookFormat(BookFormat $bookFormat): static
{
if (!$this->bookFormats->contains($bookFormat)) {
$this->bookFormats->add($bookFormat);
$bookFormat->setBook($this);
}

return $this;
}

public function removeBookFormat(BookFormat $bookFormat): static
{
if ($this->bookFormats->removeElement($bookFormat)) {
// set the owning side to null (unless already changed)
if ($bookFormat->getBook() === $this) {
$bookFormat->setBook(null);
}
}

return $this;
}
}
Loading

0 comments on commit 47b65e9

Please sign in to comment.