Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,4 @@ RUN mkdir -p data && chown bun:bun data
# run the app
USER bun
EXPOSE 3000/tcp
ENTRYPOINT [ "bun", "run", "serve" ]
ENTRYPOINT [ "bun", "run", "server/index.ts" ]
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,47 @@ Your content is automatically processed for the best e-ink reading experience:

---

## Self-hosted OPDS Docker Service

XTC.js can also run as a Docker service for a mounted CBZ library. The OPDS catalog is available at:

```text
http://<server-ip>:3000/opds
```

The default `docker-compose.yml` mounts `./library` read-only at `/library`, converts CBZ files to XTC on demand, and stores converted files in the persistent `xtcjs-data` volume under `/usr/src/app/data/opds-cache`.

```bash
mkdir -p library
docker compose up --build
```

Useful endpoints:

| Endpoint | Purpose |
|----------|---------|
| `/opds` | OPDS 1.2 root catalog |
| `/opds/books` | Paginated CBZ acquisition feed |
| `/api/opds/status` | Library and conversion status |
| `/api/opds/rescan` | Manual library rescan |

Docker conversion defaults can be changed with environment variables:

| Variable | Default |
|----------|---------|
| `LIBRARY_DIR` | `/library` |
| `OPDS_CACHE_DIR` | `/usr/src/app/data/opds-cache` |
| `OPDS_PAGE_SIZE` | `50` |
| `XTC_DEVICE` | `X4` |
| `XTC_SPLIT_MODE` | `overlap` |
| `XTC_DITHERING` | `floyd` |
| `XTC_CONTRAST` | `4` |
| `XTC_2BIT` | `false` |

Compatibility note: CrossPoint currently appears to support OPDS browsing, but its OPDS downloader is EPUB-oriented in the public source. XTC.js exposes `.xtc/.xtch` downloads through OPDS, but CrossPoint may need an upstream change to accept XTC acquisition links and preserve the downloaded file extension.

---

## Recommended Settings

### 📖 Manga & Comics
Expand Down
87 changes: 81 additions & 6 deletions bun.lock

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,18 @@ services:
build: .
ports:
- "3000:3000"
environment:
LIBRARY_DIR: /library
OPDS_CACHE_DIR: /usr/src/app/data/opds-cache
OPDS_PAGE_SIZE: "50"
XTC_DEVICE: X4
XTC_SPLIT_MODE: overlap
XTC_DITHERING: floyd
XTC_CONTRAST: "4"
XTC_2BIT: "false"
volumes:
- xtcjs-data:/usr/src/app/data
- ./library:/library:ro
restart: unless-stopped

volumes:
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"build:static": "VITE_API_URL=https://api.xtcjs.app/api vite build",
"preview": "vite preview",
"serve": "bun run server/index.ts",
"serve:api": "bun run server/api-only.ts"
"serve:api": "bun run server/api-only.ts",
"test": "bun test"
},
"dependencies": {
"@hono/vite-dev-server": "^0.24.1",
Expand All @@ -22,7 +23,8 @@
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.4.624",
"react": "^19.2.4",
"react-dom": "^19.2.4"
"react-dom": "^19.2.4",
"sharp": "^0.34.5"
},
"devDependencies": {
"@tanstack/router-plugin": "^1.158.1",
Expand Down
5 changes: 5 additions & 0 deletions server/api.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { opdsApiRoutes } from './opds/routes'
import { ensureParentDirectory } from './storage'

const api = new Hono()

api.use('*', cors({
origin: ['https://xtcjs.app', 'http://localhost:5173'],
}))

api.route('/opds', opdsApiRoutes)

// Config
const FLUSH_INTERVAL_MS = 60 * 60 * 1000 // 1 hour default

Expand Down Expand Up @@ -48,6 +52,7 @@ async function initDatabase() {
const { Database } = await import('bun:sqlite')
const DB_PATH = import.meta.dir + '/../data/stats.db'

await ensureParentDirectory(DB_PATH)
db = new Database(DB_PATH, { create: true })
db.run('PRAGMA journal_mode = WAL')

Expand Down
2 changes: 2 additions & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Hono } from 'hono'
import { api } from './api'
import { opdsRoutes } from './opds/routes'

const app = new Hono()

// Mount API routes under /api
app.route('/api', api)
app.route('/opds', opdsRoutes)

// Serve static files and SPA fallback using Bun.file()
app.get('*', async (c) => {
Expand Down
90 changes: 90 additions & 0 deletions server/opds/cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ConversionCache } from './cache'
import type { LibraryBook, ServerConversionOptions } from './types'

const tempDirs: string[] = []

async function makeTempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'xtcjs-cache-'))
tempDirs.push(dir)
return dir
}

const options: ServerConversionOptions = {
device: 'X4',
splitMode: 'overlap',
dithering: 'floyd',
contrast: 4,
is2bit: false,
orientation: 'landscape',
}

function book(overrides: Partial<LibraryBook> = {}): LibraryBook {
return {
id: 'book-1',
relativePath: 'book.cbz',
absolutePath: '/library/book.cbz',
title: 'Book',
size: 100,
mtimeMs: 1000,
updated: '2026-05-23T00:00:00.000Z',
...overrides,
}
}

afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
})

describe('ConversionCache', () => {
test('returns an existing cache hit without converting again', async () => {
const dir = await makeTempDir()
const cache = new ConversionCache(dir)
let conversions = 0

const first = await cache.getOrCreate(book(), options, async () => {
conversions++
return new ArrayBuffer(4)
})
const second = await cache.getOrCreate(book(), options, async () => {
conversions++
return new ArrayBuffer(8)
})

expect(first.path).toBe(second.path)
expect(conversions).toBe(1)
})

test('source changes produce a different cache entry', async () => {
const dir = await makeTempDir()
const cache = new ConversionCache(dir)

const first = await cache.getOrCreate(book({ size: 100 }), options, async () => new ArrayBuffer(4))
const second = await cache.getOrCreate(book({ size: 101 }), options, async () => new ArrayBuffer(4))

expect(second.path).not.toBe(first.path)
})

test('concurrent requests share one conversion promise', async () => {
const dir = await makeTempDir()
const cache = new ConversionCache(dir)
let conversions = 0

const convert = async () => {
conversions++
await Bun.sleep(20)
return new ArrayBuffer(4)
}

const [first, second] = await Promise.all([
cache.getOrCreate(book(), options, convert),
cache.getOrCreate(book(), options, convert),
])

expect(first.path).toBe(second.path)
expect(conversions).toBe(1)
})
})
76 changes: 76 additions & 0 deletions server/opds/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { createHash } from 'node:crypto'
import { mkdir, rename, stat } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type { CachedConversion, LibraryBook, ServerConversionOptions } from './types'

type Converter = () => Promise<ArrayBuffer>

const inFlight = new Map<string, Promise<CachedConversion>>()

export class ConversionCache {
constructor(private readonly cacheDir: string) {}

async getOrCreate(
book: LibraryBook,
options: ServerConversionOptions,
convert: Converter
): Promise<CachedConversion> {
const key = getCacheKey(book, options)
const filename = `${sanitizeFilename(book.title)}.${options.is2bit ? 'xtch' : 'xtc'}`
const path = join(this.cacheDir, key, filename)

const cached = await getExisting(path, filename)
if (cached) return cached

const existing = inFlight.get(key)
if (existing) return existing

const promise = this.create(path, filename, convert)
inFlight.set(key, promise)
try {
return await promise
} finally {
inFlight.delete(key)
}
}

private async create(path: string, filename: string, convert: Converter): Promise<CachedConversion> {
await mkdir(dirname(path), { recursive: true })
const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`
const data = await convert()
await Bun.write(tmpPath, data)
await rename(tmpPath, path)
const info = await stat(path)
return { path, filename, size: info.size }
}
}

function getCacheKey(book: LibraryBook, options: ServerConversionOptions): string {
return createHash('sha256')
.update(book.relativePath)
.update('\0')
.update(String(book.size))
.update('\0')
.update(String(book.mtimeMs))
.update('\0')
.update(JSON.stringify(options))
.digest('hex')
}

async function getExisting(path: string, filename: string): Promise<CachedConversion | null> {
try {
const info = await stat(path)
if (!info.isFile()) return null
return { path, filename, size: info.size }
} catch {
return null
}
}

export function sanitizeFilename(value: string): string {
const sanitized = value
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
.replace(/\s+/g, ' ')
.trim()
return sanitized || 'book'
}
87 changes: 87 additions & 0 deletions server/opds/cbz-converter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, test } from 'bun:test'
import JSZip from 'jszip'
import sharp from 'sharp'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { convertCbzFileToXtc } from './cbz-converter'
import type { ServerConversionOptions } from './types'

const tempDirs: string[] = []

async function makeTempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'xtcjs-converter-'))
tempDirs.push(dir)
return dir
}

async function writeImageCbz(path: string): Promise<void> {
const png = await sharp({
create: {
width: 120,
height: 180,
channels: 4,
background: { r: 255, g: 255, b: 255, alpha: 1 },
},
})
.composite([{
input: await sharp({
create: {
width: 60,
height: 80,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 1 },
},
}).png().toBuffer(),
left: 30,
top: 40,
}])
.png()
.toBuffer()

const zip = new JSZip()
zip.file('001.png', png)
await writeFile(path, Buffer.from(await zip.generateAsync({ type: 'uint8array' })))
}

function options(overrides: Partial<ServerConversionOptions> = {}): ServerConversionOptions {
return {
device: 'X4',
splitMode: 'nosplit',
dithering: 'none',
contrast: 0,
is2bit: false,
orientation: 'portrait',
...overrides,
}
}

afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
})

describe('convertCbzFileToXtc', () => {
test('converts a tiny CBZ to a valid XTC with X4 page dimensions', async () => {
const dir = await makeTempDir()
const cbzPath = join(dir, 'book.cbz')
await writeImageCbz(cbzPath)

const result = await convertCbzFileToXtc(cbzPath, options())
const view = new DataView(result)

expect(String.fromCharCode(...new Uint8Array(result, 0, 3))).toBe('XTC')
expect(view.getUint16(6, true)).toBe(1)
expect(view.getUint16(60, true)).toBe(480)
expect(view.getUint16(62, true)).toBe(800)
})

test('converts to XTCH when 2-bit output is enabled', async () => {
const dir = await makeTempDir()
const cbzPath = join(dir, 'book.cbz')
await writeImageCbz(cbzPath)

const result = await convertCbzFileToXtc(cbzPath, options({ is2bit: true }))

expect(String.fromCharCode(...new Uint8Array(result, 0, 4))).toBe('XTCH')
})
})
Loading