diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..f85449a1a
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+server/bin/kepubify/* binary
diff --git a/Dockerfile b/Dockerfile
index 1df99bd21..7b9755456 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -50,8 +50,9 @@ ENV PORT=3000
COPY --from=server-builder --chown=node:node /deploy ./
COPY --from=client-builder --chown=node:node /app/client/dist ./public
COPY --from=server-builder --chown=node:node /app/server/entrypoint.sh ./entrypoint.sh
+COPY --chown=node:node server/bin/kepubify/ ./bin/kepubify/
-RUN chmod +x /app/entrypoint.sh && mkdir -p /books /data/covers /data/book-bucket /tmp && chown -R node:node /data /tmp
+RUN chmod +x /app/entrypoint.sh /app/bin/kepubify/* && mkdir -p /books /data/covers /data/book-bucket /tmp && chown -R node:node /data /tmp
EXPOSE 3000
diff --git a/client/src/features/settings/KoboSettings.vue b/client/src/features/settings/KoboSettings.vue
index e520c7b54..0c333c875 100644
--- a/client/src/features/settings/KoboSettings.vue
+++ b/client/src/features/settings/KoboSettings.vue
@@ -203,10 +203,6 @@ async function saveSettings() {
Copy
-
- On your Kobo device, go to Settings → Account → Add account → Other and enter this URL.
- Any username/password will work.
-
({
}));
import { chmod, mkdir, stat, writeFile } from 'fs/promises';
+import { join } from 'path';
import { KepubifyBinaryService } from './kepubify-binary.service';
@@ -14,11 +15,13 @@ const mkdirMock = vi.mocked(mkdir);
const statMock = vi.mocked(stat);
const writeFileMock = vi.mocked(writeFile);
-describe('KepubifyBinaryService', () => {
- const config = {
- get: vi.fn().mockReturnValue('/app-data'),
- };
+const config = { get: vi.fn().mockReturnValue('/app-data') };
+function makeService() {
+ return new KepubifyBinaryService(config as never);
+}
+
+describe('KepubifyBinaryService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
@@ -28,79 +31,156 @@ describe('KepubifyBinaryService', () => {
vi.restoreAllMocks();
});
- it('detects expected binary names by platform/arch', () => {
- const service = new KepubifyBinaryService(config as never);
+ describe('detectBinaryName', () => {
+ it.each([
+ ['darwin', 'arm64', 'kepubify-darwin-arm64'],
+ ['darwin', 'x64', 'kepubify-darwin-64bit'],
+ ['linux', 'x64', 'kepubify-linux-64bit'],
+ ['linux', 'arm64', 'kepubify-linux-arm64'],
+ ['linux', 'arm', 'kepubify-linux-arm'],
+ ['linux', 'ia32', 'kepubify-linux-32bit'],
+ ])('platform=%s arch=%s → %s', (platform, arch, expected) => {
+ const service = makeService();
+ vi.spyOn(process, 'platform', 'get').mockReturnValue(platform as NodeJS.Platform);
+ vi.spyOn(process, 'arch', 'get').mockReturnValue(arch);
+ expect((service as any).detectBinaryName()).toBe(expected);
+ });
- vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
- vi.spyOn(process, 'arch', 'get').mockReturnValue('arm64');
- expect((service as any).detectBinaryName()).toBe('kepubify-darwin-arm64');
-
- vi.spyOn(process, 'platform', 'get').mockReturnValue('linux');
- vi.spyOn(process, 'arch', 'get').mockReturnValue('x64');
- expect((service as any).detectBinaryName()).toBe('kepubify-linux-64bit');
+ it('throws for unsupported platforms', () => {
+ const service = makeService();
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('win32' as NodeJS.Platform);
+ vi.spyOn(process, 'arch', 'get').mockReturnValue('x64');
+ expect(() => (service as any).detectBinaryName()).toThrow('Unsupported platform');
+ });
});
- it('throws for unsupported platforms', () => {
- const service = new KepubifyBinaryService(config as never);
- vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
- vi.spyOn(process, 'arch', 'get').mockReturnValue('x64');
+ describe('getBinaryPath', () => {
+ it('caches result and only resolves once across multiple calls', async () => {
+ const service = makeService();
+ vi.spyOn(service as any, 'detectBinaryName').mockReturnValue('kepubify-linux-64bit');
+ const resolveSpy = vi.spyOn(service as any, 'resolveBinaryPath').mockResolvedValue('/resolved/kepubify');
- expect(() => (service as any).detectBinaryName()).toThrow('Unsupported platform');
+ const first = await service.getBinaryPath();
+ const second = await service.getBinaryPath();
+
+ expect(first).toBe('/resolved/kepubify');
+ expect(second).toBe('/resolved/kepubify');
+ expect(resolveSpy).toHaveBeenCalledTimes(1);
+ });
});
- it('caches computed binary path after first resolution', async () => {
- const service = new KepubifyBinaryService(config as never);
- const detectSpy = vi.spyOn(service as any, 'detectBinaryName').mockReturnValue('kepubify-linux-64bit');
- const ensureSpy = vi.spyOn(service as any, 'ensureBinary').mockResolvedValue(undefined);
+ describe('resolveBinaryPath', () => {
+ it('returns bundled path when binary is present in bin/ dir', async () => {
+ const service = makeService();
+ const expectedBundled = join(process.cwd(), 'bin', 'kepubify', 'kepubify-linux-64bit');
+ statMock.mockResolvedValueOnce({} as never);
- await expect(service.getBinaryPath()).resolves.toBe('/app-data/.tools/kepubify/kepubify-linux-64bit');
- await expect(service.getBinaryPath()).resolves.toBe('/app-data/.tools/kepubify/kepubify-linux-64bit');
+ const result = await (service as any).resolveBinaryPath('kepubify-linux-64bit');
- expect(detectSpy).toHaveBeenCalledTimes(1);
- expect(ensureSpy).toHaveBeenCalledTimes(1);
+ expect(result).toBe(expectedBundled);
+ expect(chmodMock).toHaveBeenCalledWith(expectedBundled, 0o755);
+ expect(mkdirMock).not.toHaveBeenCalled();
+ });
+
+ it('falls back to download path when bundled binary is missing', async () => {
+ const service = makeService();
+ statMock
+ .mockRejectedValueOnce(new Error('ENOENT')) // bundled path missing
+ .mockResolvedValueOnce({} as never); // download cache hit
+
+ const result = await (service as any).resolveBinaryPath('kepubify-linux-64bit');
+
+ expect(result).toBe('/app-data/.tools/kepubify/kepubify-linux-64bit');
+ });
+
+ it('downloads and returns download path when neither bundled nor cached', async () => {
+ const service = makeService();
+ statMock
+ .mockRejectedValueOnce(new Error('ENOENT')) // bundled missing
+ .mockRejectedValueOnce(new Error('ENOENT')); // download cache missing
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)) }));
+
+ const result = await (service as any).resolveBinaryPath('kepubify-linux-64bit');
+
+ expect(result).toBe('/app-data/.tools/kepubify/kepubify-linux-64bit');
+ expect(mkdirMock).toHaveBeenCalledWith('/app-data/.tools/kepubify', { recursive: true });
+ });
});
- it('ensureBinary keeps executable bit for already-cached binaries', async () => {
- const service = new KepubifyBinaryService(config as never);
- statMock.mockResolvedValue({} as never);
+ describe('ensureDownloaded', () => {
+ it('chmods and returns without downloading when binary is already cached', async () => {
+ const service = makeService();
+ statMock.mockResolvedValueOnce({} as never);
- await expect((service as any).ensureBinary('/tmp/kepubify', 'kepubify-linux-64bit', '/tmp')).resolves.toBeUndefined();
+ await (service as any).ensureDownloaded('/cache/kepubify', 'kepubify-linux-64bit', '/cache');
- expect(chmodMock).toHaveBeenCalledWith('/tmp/kepubify', 0o755);
- expect(mkdirMock).not.toHaveBeenCalled();
- });
+ expect(chmodMock).toHaveBeenCalledWith('/cache/kepubify', 0o755);
+ expect(mkdirMock).not.toHaveBeenCalled();
+ expect(writeFileMock).not.toHaveBeenCalled();
+ });
- it('downloads missing binary and writes executable file', async () => {
- const service = new KepubifyBinaryService(config as never);
- statMock.mockRejectedValue(new Error('missing'));
- vi.stubGlobal(
- 'fetch',
- vi.fn().mockResolvedValue({
- ok: true,
- arrayBuffer: () => Promise.resolve(Uint8Array.from([1, 2, 3]).buffer),
- }),
- );
+ it('downloads, writes, and chmods when binary is not cached', async () => {
+ const service = makeService();
+ statMock.mockRejectedValueOnce(new Error('ENOENT'));
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: true,
+ arrayBuffer: () => Promise.resolve(Uint8Array.from([0xde, 0xad, 0xbe, 0xef]).buffer),
+ }),
+ );
- await (service as any).ensureBinary('/tmp/kepubify', 'kepubify-linux-64bit', '/tmp');
+ await (service as any).ensureDownloaded('/cache/kepubify', 'kepubify-linux-64bit', '/cache/dir');
- expect(mkdirMock).toHaveBeenCalledWith('/tmp', { recursive: true });
- expect(writeFileMock).toHaveBeenCalledWith('/tmp/kepubify', Buffer.from([1, 2, 3]));
- expect(chmodMock).toHaveBeenCalledWith('/tmp/kepubify', 0o755);
- });
+ expect(mkdirMock).toHaveBeenCalledWith('/cache/dir', { recursive: true });
+ expect(writeFileMock).toHaveBeenCalledWith('/cache/kepubify', Buffer.from([0xde, 0xad, 0xbe, 0xef]));
+ expect(chmodMock).toHaveBeenCalledWith('/cache/kepubify', 0o755);
+ });
- it('throws when binary download returns non-ok status', async () => {
- const service = new KepubifyBinaryService(config as never);
- statMock.mockRejectedValue(new Error('missing'));
- vi.stubGlobal(
- 'fetch',
- vi.fn().mockResolvedValue({
- ok: false,
- status: 503,
- }),
- );
+ it('fetches from the bookorbit/bookorbit-tools URL (not neonsolstice)', async () => {
+ const service = makeService();
+ statMock.mockRejectedValueOnce(new Error('ENOENT'));
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)) });
+ vi.stubGlobal('fetch', fetchMock);
- await expect((service as any).ensureBinary('/tmp/kepubify', 'kepubify-linux-64bit', '/tmp')).rejects.toThrow(
- 'Failed to download kepubify: HTTP 503',
- );
+ await (service as any).ensureDownloaded('/cache/kepubify', 'kepubify-darwin-arm64', '/cache/dir');
+
+ const calledUrl: string = fetchMock.mock.calls[0][0];
+ expect(calledUrl).toContain('bookorbit/bookorbit-tools');
+ expect(calledUrl).not.toContain('neonsolstice');
+ expect(calledUrl).toContain('kepubify-darwin-arm64');
+ });
+
+ it('throws when download responds with a non-ok status', async () => {
+ const service = makeService();
+ statMock.mockRejectedValueOnce(new Error('ENOENT'));
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 }));
+
+ await expect((service as any).ensureDownloaded('/cache/kepubify', 'kepubify-linux-64bit', '/cache/dir')).rejects.toThrow(
+ 'Failed to download kepubify: HTTP 404',
+ );
+ expect(writeFileMock).not.toHaveBeenCalled();
+ });
+
+ it('throws when download responds with a 503', async () => {
+ const service = makeService();
+ statMock.mockRejectedValueOnce(new Error('ENOENT'));
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 503 }));
+
+ await expect((service as any).ensureDownloaded('/cache/kepubify', 'kepubify-linux-64bit', '/cache/dir')).rejects.toThrow(
+ 'Failed to download kepubify: HTTP 503',
+ );
+ });
+
+ it('re-throws network-level fetch errors', async () => {
+ const service = makeService();
+ statMock.mockRejectedValueOnce(new Error('ENOENT'));
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED')));
+
+ await expect((service as any).ensureDownloaded('/cache/kepubify', 'kepubify-linux-64bit', '/cache/dir')).rejects.toThrow(
+ 'connect ECONNREFUSED',
+ );
+ expect(writeFileMock).not.toHaveBeenCalled();
+ });
});
});
diff --git a/server/src/modules/kobo/services/kepubify-binary.service.ts b/server/src/modules/kobo/services/kepubify-binary.service.ts
index 07d1fa9df..ad24cf450 100644
--- a/server/src/modules/kobo/services/kepubify-binary.service.ts
+++ b/server/src/modules/kobo/services/kepubify-binary.service.ts
@@ -4,28 +4,48 @@ import { join } from 'path';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
-const KEPUBIFY_BASE_URL = 'https://github.com/neonsolstice/projectx-tools/raw/main/kepubify/';
+const KEPUBIFY_BASE_URL = 'https://github.com/bookorbit/bookorbit-tools/raw/main/kepubify/';
@Injectable()
export class KepubifyBinaryService {
private readonly logger = new Logger(KepubifyBinaryService.name);
private readonly appDataPath: string;
+ private readonly bundledBinDir: string;
private cachedBinaryPath: string | null = null;
constructor(private readonly config: ConfigService) {
this.appDataPath = this.config.get('storage.appDataPath')!;
+ this.bundledBinDir = join(process.cwd(), 'bin', 'kepubify');
}
async getBinaryPath(): Promise {
if (this.cachedBinaryPath) return this.cachedBinaryPath;
const binaryName = this.detectBinaryName();
- const toolsDir = join(this.appDataPath, '.tools', 'kepubify');
- const binaryPath = join(toolsDir, binaryName);
- await this.ensureBinary(binaryPath, binaryName, toolsDir);
+ const binaryPath = await this.resolveBinaryPath(binaryName);
this.cachedBinaryPath = binaryPath;
return binaryPath;
}
+ private async resolveBinaryPath(binaryName: string): Promise {
+ const bundledPath = join(this.bundledBinDir, binaryName);
+ try {
+ await stat(bundledPath);
+ } catch {
+ // Not bundled (e.g. macOS dev) - fall back to download
+ const toolsDir = join(this.appDataPath, '.tools', 'kepubify');
+ const downloadPath = join(toolsDir, binaryName);
+ await this.ensureDownloaded(downloadPath, binaryName, toolsDir);
+ return downloadPath;
+ }
+ try {
+ await chmod(bundledPath, 0o755);
+ } catch {
+ // Read-only filesystem (e.g. Docker with read_only: true) - permissions
+ // are already set correctly by the Dockerfile, so this is safe to ignore.
+ }
+ return bundledPath;
+ }
+
private detectBinaryName(): string {
const platform = process.platform;
const arch = process.arch;
@@ -41,27 +61,44 @@ export class KepubifyBinaryService {
throw new Error(`Unsupported platform for kepubify: ${platform} / ${arch}`);
}
- private async ensureBinary(binaryPath: string, binaryName: string, toolsDir: string): Promise {
+ private async ensureDownloaded(binaryPath: string, binaryName: string, toolsDir: string): Promise {
try {
await stat(binaryPath);
await chmod(binaryPath, 0o755);
return;
} catch {
- // Binary not cached, download it
+ // Not cached - download
}
const url = `${KEPUBIFY_BASE_URL}${binaryName}`;
- this.logger.log(`Downloading kepubify binary from ${url}`);
+ const startedAt = Date.now();
+ this.logger.log(`[kepubify.download] [start] binaryName=${binaryName} - downloading kepubify binary`);
await mkdir(toolsDir, { recursive: true });
- const response = await fetch(url);
- if (!response.ok) throw new Error(`Failed to download kepubify: HTTP ${response.status}`);
+ let response: Response;
+ try {
+ response = await fetch(url);
+ } catch (err) {
+ const durationMs = Date.now() - startedAt;
+ this.logger.error(
+ `[kepubify.download] [fail] binaryName=${binaryName} durationMs=${durationMs} errorClass=${(err as Error).constructor.name} error="${(err as Error).message}" - kepubify download failed`,
+ );
+ throw err;
+ }
+
+ if (!response.ok) {
+ const durationMs = Date.now() - startedAt;
+ this.logger.error(
+ `[kepubify.download] [fail] binaryName=${binaryName} durationMs=${durationMs} errorClass=HttpError error="HTTP ${response.status}" - kepubify download failed`,
+ );
+ throw new Error(`Failed to download kepubify: HTTP ${response.status}`);
+ }
const buffer = await response.arrayBuffer();
await writeFile(binaryPath, Buffer.from(buffer));
await chmod(binaryPath, 0o755);
- this.logger.log(`Downloaded kepubify to ${binaryPath}`);
+ this.logger.log(`[kepubify.download] [end] binaryName=${binaryName} durationMs=${Date.now() - startedAt} - downloaded kepubify binary`);
}
}
diff --git a/server/src/modules/kobo/services/kobo-download.service.test.ts b/server/src/modules/kobo/services/kobo-download.service.test.ts
index d6333ee2a..ff29623ad 100644
--- a/server/src/modules/kobo/services/kobo-download.service.test.ts
+++ b/server/src/modules/kobo/services/kobo-download.service.test.ts
@@ -192,6 +192,43 @@ describe('KoboDownloadService', () => {
await expect((service as any).streamFile('/books/missing.epub', 99, 'epub', reply)).rejects.toThrow(NotFoundException);
});
+ it('streamFile uses application/epub+zip for kepub.epub format', async () => {
+ const deps = makeDeps();
+ const service = new KoboDownloadService(
+ deps.db as never,
+ deps.config as never,
+ deps.kepubifyBinaryService as never,
+ deps.settingsService as never,
+ deps.bookAccessService as never,
+ );
+ const reply = makeReply();
+ statMock.mockResolvedValueOnce({ size: 4096 } as never);
+ createReadStreamMock.mockReturnValue({} as never);
+
+ await (service as any).streamFile('/cache/44/hash.kepub.epub', 55, 'kepub.epub', reply);
+
+ expect(reply.type).toHaveBeenCalledWith('application/epub+zip');
+ expect(reply.header).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="book-55.kepub.epub"');
+ });
+
+ it('streamFile falls back to application/octet-stream for unknown formats', async () => {
+ const deps = makeDeps();
+ const service = new KoboDownloadService(
+ deps.db as never,
+ deps.config as never,
+ deps.kepubifyBinaryService as never,
+ deps.settingsService as never,
+ deps.bookAccessService as never,
+ );
+ const reply = makeReply();
+ statMock.mockResolvedValueOnce({ size: 100 } as never);
+ createReadStreamMock.mockReturnValue({} as never);
+
+ await (service as any).streamFile('/books/book.xyz', 10, 'xyz', reply);
+
+ expect(reply.type).toHaveBeenCalledWith('application/octet-stream');
+ });
+
it('streamKepub serves cached files when present', async () => {
const deps = makeDeps();
const service = new KoboDownloadService(
diff --git a/server/src/modules/kobo/services/kobo-download.service.ts b/server/src/modules/kobo/services/kobo-download.service.ts
index 5fd31c8fa..ae9247b6a 100644
--- a/server/src/modules/kobo/services/kobo-download.service.ts
+++ b/server/src/modules/kobo/services/kobo-download.service.ts
@@ -22,6 +22,7 @@ const execFileAsync = promisify(execFile);
const MIME: Record = {
epub: 'application/epub+zip',
+ 'kepub.epub': 'application/epub+zip',
pdf: 'application/pdf',
};