Updated README

This commit is contained in:
2025-09-09 20:59:08 +02:00
parent 434469dffe
commit e5918ff997
2 changed files with 452 additions and 111 deletions

View File

@@ -5,7 +5,7 @@ COPY requirements.txt .
# install system libs for Pillow (JPEG, PNG, WebP)
RUN apt-get update && apt-get install -y --no-install-recommends \
libjpeg62-turbo zlib1g libpng16-16 libwebp7 \
libjpeg62-turbo zlib1g libpng16-16 libwebp7 wget \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir -r requirements.txt

561
README.md
View File

@@ -1,183 +1,524 @@
# ComicOPDS
# 📚 ComicOPDS
A tiny, fast **OPDS 1.x** server for **CBZ** comics with ComicRack metadata.
Built with FastAPI, runs great in Docker, works nicely with Panels for iOS.
ComicOPDS is a lightweight [OPDS 1.2](https://specs.opds.io/opds-1.2) server written in Python, designed for serving **CBZ comics** with metadata extracted from `ComicInfo.xml`.
It's optimized for large libraries (10k100k+ comics), supports FastAPI + SQLite + FTS5 search, thumbnail caching, and streaming (OPDS PSE 1.1).
## Features
Works great with [Panels for iOS](https://panels.app) and other OPDS readers.
* 📁 **Browse** your library by folder (no database)
* 🔎 **Search** (OPDS 1.x + OpenSearch) across filenames and ComicInfo metadata
* ⬇️ **Download** & ▶️ **Stream** (HTTP Range support) CBZ files
* 🖼️ **Thumbnails** extracted from CBZ (first image), cached on disk
If `ComicVineIssue` exists in `ComicInfo.xml`, the cover is named `<ComicVineIssue>.jpg`
* 🏷️ **ComicInfo.xml** parsing and caching (Series, Number, Title, Publisher, Writer, Year, …)
***Warm index** persisted to `/data/index.json` (no full re-index on startup)
* 🔐 Optional **HTTP Basic Auth**
* 📊 **Dashboard** at `/dashboard` with overview stats & charts (powered by `/stats.json`)
* 🧰 Reverse-proxy friendly (domain/subpath support)
> **Scope**: CBZ **only** (by design).
<a href="https://www.buymeacoffee.com/frederikb" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="41" width="174"></a>
---
## Quick start
## 📋 Table of Contents
### 1) Folder layout (example)
- [Features](#-features)
- [Quick Start](#-quick-start)
- [Installation Methods](#-installation-methods)
- [Configuration](#-configuration)
- [API Documentation](#-api--endpoints)
- [Dashboard](#-dashboard)
- [Smart Lists](#-smart-lists)
- [Search](#-search)
- [Client Setup](#-client-setup)
- [Troubleshooting](#-troubleshooting)
- [Contributing](#-contributing)
- [License](#-license)
---
## ✨ Features
- 📂 Browse your folder hierarchy
- 🔍 Full-text search (title, series, writer, publisher, year, etc.)
- 📥 Download comics (CBZ)
- 📖 Page streaming (OPDS PSE 1.1)
- 🖼️ Thumbnail extraction & caching (from CBZ covers)
- 📊 Dashboard with stats & charts
- 🧠 Smart Lists (saved search filters)
- 🔐 Optional Basic Auth
- 🐋 Runs easily with Docker / Docker Compose
- ⚡ Fast indexing with SQLite FTS5
- 🔄 File system watching for auto-updates
- 📱 Mobile-optimized dashboard
---
## 🚀 Quick Start
### Prerequisites
- Docker and Docker Compose installed
- Comic collection in CBZ format with `ComicInfo.xml` metadata
### Folder Structure
Your comics should be organized like this:
```
/your/comics/
Batman (2016)/
Batman (2016) - 001.cbz # contains ComicInfo.xml
Batman (2016) - 002.cbz
Saga/
Saga - 001.cbz
├── Batman (2016)/
├── Batman (2016) - 001.cbz # contains ComicInfo.xml
└── Batman (2016) - 002.cbz
├── Saga/
└── Saga - 001.cbz
└── ...
```
Make sure each CBZ has a `ComicInfo.xml` inside (ComicRack-compatible).
**Recommended**: I use [ComicRack CE](https://github.com/maforget/ComicRackCE) to organize my comic library and generate proper `ComicInfo.xml` metadata. For a comprehensive guide on setting up an optimal comic library structure and metadata management, see my detailed guide at: **[https://comicrack.baerentsen.space/](https://comicrack.baerentsen.space/)**
### 2) Docker Compose
### Docker Compose Setup
Create a `docker-compose.yml` file:
```yaml
services:
comicopds:
build: . # build the image from the included Dockerfile
image: comicopds:latest
container_name: comicopds
image: gitea.baerentsen.space/frederikbaerentsen/comicopds:latest
restart: unless-stopped
ports:
- "8080:8080"
- "8382:8080"
environment:
CONTENT_BASE_DIR: /library
PAGE_SIZE: "50"
SERVER_BASE: "https://comics.example.com" # your public origin (or http://localhost:8080)
URL_PREFIX: "" # e.g. "/comics" if served under a subpath
OPDS_BASIC_USER: "admin" # leave empty to disable auth
OPDS_BASIC_PASS: "change-me"
SERVER_BASE: "http://192.168.10.10:8382" # Replace with your server IP/domain
URL_PREFIX: "" # set to "/comics" if behind a reverse proxy subpath
DISABLE_AUTH: "false" # set to true to disable authentication
OPDS_BASIC_USER: "admin"
OPDS_BASIC_PASS: "change-me-please" # Use a strong password!
ENABLE_WATCH: "false" # auto-update index on file changes
PRECACHE_THUMBS: "false" # set true to pre-cache thumbs on reindex
AUTO_INDEX_ON_START: "false" # skip reindexing on each container start
volumes:
- /path/to/your/comics:/library:ro
- ./data:/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
interval: 30s
timeout: 3s
retries: 3
- "./comics:/library:ro"
- "./data:/data"
```
Build & run:
### Launch Commands
```bash
docker compose up --build
# Build and start
docker compose build
docker compose up -d
# View logs
docker compose logs -f
# Stop
docker compose down
```
On the first run, Docker will build the image from the included Dockerfile.
On later runs, Docker will reuse the existing image unless you change the code/Dockerfile (then it will rebuild automatically).
### Access Points
Visit:
- 📡 **OPDS Feed**: http://localhost:8382/
- 📊 **Dashboard**: http://localhost:8382/dashboard
- 🧠 **Smart Lists**: http://localhost:8382/search
* OPDS root feed: `https://comics.example.com/` (or `http://localhost:8080/`)
* Dashboard: `https://comics.example.com/dashboard`
<!-- --- -->
> If you run behind a reverse proxy **and** a subpath (e.g. `/comics`), set
> `SERVER_BASE=https://example.com` and `URL_PREFIX=/comics`.
> Uvicorn is started with `--proxy-headers`, so `X-Forwarded-*` are respected.
<!-- ## 📖 Documentation
- Reindex library: click the Reindex button in dashboard
- Pre-cache thumbnails: click Pre-cache Thumbnails (generates all covers ahead of time)
If covers aren't pre-cached, each cover will be generated when needed.
- Search: available in Panels & other OPDS apps
- Smart Lists: create saved filters on /search -->
---
## OPDS endpoints
## 🔧 Configuration
* `GET /` → OPDS start feed (Atom)
* `GET /opds` → folder browsing feed (`?path=` and `?page=`)
* `GET /opds/search.xml` → OpenSearch description
* `GET /opds/search?q=…&page=` → search results feed (Atom)
* `GET /download?path=…` → download CBZ (correct MIME)
* `GET /stream?path=…` → range-enabled streaming (206 Partial Content)
* `GET /thumb?path=…` → JPEG cover (from CBZs first image; cached)
* `GET /dashboard` → HTML dashboard
* `GET /stats.json` → library stats for the dashboard
* `GET /healthz` → “ok” healthcheck
### Environment Variables
All routes can be protected by HTTP Basic (set `OPDS_BASIC_USER/PASS`).
| Variable | Default | Description |
|-----------------------|-------------|-------------|
| `CONTENT_BASE_DIR` | `/library` | Path inside the container where your comics are stored (mounted volume). |
| `PAGE_SIZE` | `50` | Number of entries per page in OPDS feeds. Increase/decrease depending on client performance. |
| `SERVER_BASE` | (required) | Public base URL (e.g. `http://10.0.0.1:8382` or `https://comics.example.com`). Used in generated OPDS links. |
| `URL_PREFIX` | `""` | Path prefix when serving behind a reverse proxy (e.g. `/comics`). Leave empty if served at root or subdomain. |
| `DISABLE_AUTH` | `false` | If `true`, disables authentication completely (public access). |
| `OPDS_BASIC_USER` | `admin` | Username for HTTP Basic Auth. Ignored if `DISABLE_AUTH=true`. |
| `OPDS_BASIC_PASS` | `change-me` | Password for HTTP Basic Auth. Ignored if `DISABLE_AUTH=true`. |
| `ENABLE_WATCH` | `false` | Watch filesystem for changes and update index incrementally. (`true`/`false`). |
| `AUTO_INDEX_ON_START` | `false` | If `true`, reindexes library on every container start. Recommended `false` for large libraries. |
| `PRECACHE_THUMBS` | `false` | If `true`, enables thumbnail generation when reindexing or via dashboard. |
| `PRECACHE_ON_START` | `false` | If `true`, automatically triggers full thumbnail pre-cache at container start. Recommended `false` for large libraries. |
| `THUMB_WORKERS` | `3` | Number of parallel workers for thumbnail generation. Tune for your CPU/IO capacity. |
### 📚 Recommended Settings for Large Libraries (30k100k+ comics)
For very large collections, some defaults should be adjusted to avoid long startup times and high resource usage:
#### Indexing Settings
- `AUTO_INDEX_ON_START=false` → prevents reindexing every time the container starts
- Use the **Reindex** button on the dashboard when needed instead
#### Thumbnail Settings
- `PRECACHE_ON_START=false` → don't pre-cache on every boot
- Run pre-cache manually via the dashboard button after a big import
- `THUMB_WORKERS=4``6` → if you have enough CPU/IO, increase worker count for faster thumbnail generation
#### Performance Settings
- Keep `PAGE_SIZE=50` unless your client struggles with large feeds
- Some OPDS readers work better with smaller pages (e.g. `25`)
#### Security Settings
- For private servers behind a VPN, you can disable auth: `DISABLE_AUTH=true`
- Otherwise, keep Basic Auth enabled (`OPDS_BASIC_USER` / `OPDS_BASIC_PASS`)
These settings ensure the container starts faster, avoids unnecessary reprocessing, and lets you control when heavy tasks (indexing, thumbnailing) happen.
---
## How it works
## 🌐 API & Endpoints
### Warm index
ComicOPDS exposes both user-facing endpoints (for OPDS clients and the dashboard) and admin/debug endpoints.
* On first run, scans `/library` for `.cbz`, reads **`ComicInfo.xml` once**, and writes `/data/index.json`.
* On later startups, **reuses cached metadata** if file `size` & `mtime` match; only new/changed files are opened.
* Thumbnails live in `/data/thumbs/`:
### 📡 OPDS Endpoints
* If `<ComicVineIssue>` exists → `THUMBS/<comicvine_issue>.jpg`
* Else → `THUMBS/<sha1-of-relpath>.jpg`
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/` | `GET` | Root OPDS catalog feed (same as `/opds`) |
| `/opds` | `GET` | Root OPDS catalog feed. Supports browsing by folder and smart lists. |
| `/opds?path=...` | `GET` | Browse into a subfolder (series, publisher, etc.). |
| `/opds/search.xml` | `GET` | [OpenSearch 1.1](https://opensearch.org/) descriptor. Tells OPDS clients how to search. |
| `/opds/search?q=...&page=...` | `GET` | Perform a search query (returns OPDS feed of matching comics). |
| `/download?path=...` | `GET` | Download a `.cbz` file. Supports HTTP range requests. |
| `/stream?path=...` | `GET` | Stream a `.cbz` file (content-type `application/vnd.comicbook+zip`). |
| `/pse/pages?path=...` | `GET` | OPDS PSE 1.1 page streaming (individual pages as images). Used by Panels and similar clients. |
| `/thumb?path=...` | `GET` | Get thumbnail image for a comic (JPEG format). |
### Metadata fields used (from ComicInfo.xml)
### 📊 Dashboard & Stats
* Titles/ordering: `Series`, `Number`, `Volume`, `Title`
* People: `Writer`, `Penciller`, `Inker`, `Colorist`, `Letterer`, `CoverArtist`
* Catalog: `Publisher`, `Imprint`, `Genre`, `Tags`, `Characters`, `Teams`, `Locations`
* Dates: `Year`, `Month`, `Day``dcterms:issued`
* Extras: `ComicVineIssue` (for stable cover filenames)
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/dashboard` | `GET` | Dashboard (HTML UI with Bootstrap & Chart.js). |
| `/stats.json` | `GET` | JSON with library statistics (total comics, unique series, publishers, etc.). |
| `/search` | `GET` | Smart Lists UI (create/edit saved searches). |
| `/healthz` | `GET` | Health check endpoint (returns "ok"). |
### Dashboard stats
### 🛠️ Admin Endpoints
* **Overview**: total comics, unique series, publishers, formats
* **Charts**:
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/admin/reindex` | `POST` | Trigger a full library reindex. Shows progress in dashboard. |
| `/admin/thumbs/precache` | `POST` | Trigger full thumbnail pre-cache. Shows progress in dashboard. |
| `/index/status` | `GET` | JSON status of current indexing task. |
| `/thumbs/status` | `GET` | JSON status of current thumbnail caching task. |
| `/thumbs/errors/log` | `GET` | Download the thumbnail extraction error log (`/data/thumbs_errors.log`). |
* Publishers (doughnut)
* Publication timeline by year (line)
* Formats breakdown (bar — mostly CBZ)
* Top writers (horizontal bar)
### 🧪 Debug Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/debug/children?path=...` | `GET` | JSON list of child items (files/folders) under a path. Useful for testing indexing. |
| `/debug/fts` | `GET` | Returns `{ "fts5": true/false }` indicating whether SQLite FTS5 is enabled. |
⚠️ **Note:**
- Admin and debug endpoints require Basic Auth unless `DISABLE_AUTH=true` is set.
- OPDS endpoints follow the OPDS 1.2 specification and should work with Panels and other compliant OPDS clients.
---
## Using with Panels (iOS)
## 📊 Dashboard
In Panels → **Add Catalog****OPDS**:
> Access through `/dashboard`
* **URL**: your OPDS root (e.g. `https://comics.example.com/`)
* Add your username/password if you enabled Basic Auth
* Panels will show covers via OPDS `image` links and use your folder structure for browsing
The dashboard provides a comprehensive overview of your comic library:
Search in Panels maps to `/opds/search`.
### Features
- **Library Statistics**: Total comics, unique series, publishers
- **Interactive Charts**:
- Publishers distribution (doughnut chart)
- Publication timeline (line chart)
- Top writers (horizontal bar chart)
- Format breakdown
- **Management Tools**:
- Reindex library button
- Pre-cache thumbnails button
- Progress bars for ongoing operations
- **Error Monitoring**:
- Thumbnail extraction error counter
- Downloadable error log
---
## Environment variables
## 🧠 Smart Lists
| Name | Default | Purpose |
| ------------------ | ----------------------- | -------------------------------------------------- |
| `CONTENT_BASE_DIR` | `/library` | Path inside the container where comics are mounted |
| `PAGE_SIZE` | `50` | Items per OPDS page |
| `SERVER_BASE` | `http://localhost:8080` | Public origin used to build absolute OPDS links |
| `URL_PREFIX` | `""` | If serving under a subpath (e.g. `/comics`) |
| `OPDS_BASIC_USER` | *(empty)* | Username for HTTP Basic (if empty → auth off) |
| `OPDS_BASIC_PASS` | *(empty)* | Password for HTTP Basic |
> Access through `/search`
Smart Lists allow you to create saved search filters that appear as "virtual folders" in your OPDS feed.
### Creating Smart Lists
#### Simple Filters
Add filters using the web interface:
- `series=Batman`
- `year=2024`
- `publisher=DC Comics`
#### Advanced Filters
Create complex queries with multiple conditions:
```
series contains 'Scrooge McDuck'
volume equals 1953
number >= 285
number <= 297
```
#### JSON Configuration Example
For advanced users, Smart Lists are stored in `/data/smartlist.json`:
```
{
"name": "Maul + Vader (1-5)",
"slug": "maul-vader-1-5",
"groups": [
{
"rules": [
{
"not": false,
"field": "series",
"op": "contains",
"value": "Maul"
},
{
"not": false,
"field": "number",
"op": "<=",
"value": "5"
},
{
"not": false,
"field": "format",
"op": "equals",
"value": "Limited Series"
}
]
},
{
"rules": [
{
"not": false,
"field": "series",
"op": "contains",
"value": "Vader"
},
{
"not": false,
"field": "number",
"op": "<=",
"value": "5"
},
{
"not": false,
"field": "format",
"op": "equals",
"value": "Main Series"
}
]
}
],
"sort": "series_number",
"limit": 0,
"distinct_by": "",
"distinct_mode": "oldest"
}
```
**Maul + Vader (1-5)**:
- Group 1:
- series contains "Maul"
- number <= 5
- format = "Limited Series"
- Group 2:
- series contains "Vader"
- number <= 5
- format = "Main Series"
- Sort:
- series_number
- limit: 0
- Distinct: no
### Supported Operations
- `equals`, `contains`, `startswith`, `endswith`
- `=`, `!=`, `>=`, `<=`, `>`, `<` (for numeric fields)
- `not` modifier for any operation
### "Distinct by series and volume (latest)"
When that option is enabled, a smart list will return at most one comic per series and volume.
For each series, it picks the latest issue, using this tie-break:
1. Newer year (cast to integer)
2. If year ties: higher number (cast to integer)
3. If number ties: newer file mtime (last modified time)
So you get a de-duplicated "what's the newest issue for each series?" view.
**Use Cases**:
- A clean "latest per series" shelf (e.g., to see what's new without 300 issues of Batman).
- Weekly pulls / backlog triage: combine with filters like `publisher=Image` or `year >= 2020`.
**Important details / edge cases**
- Numeric casting: blank or non-numeric `year`/`number` are treated as `NULL` → effectively `0`, so those won't beat entries with proper numbers (eg. `16A`).
**Example use**
- "Latest Image series":
- Rules: `publisher = "Image Comics"`, `year >= 2018`
- Distinct by series: on
→ One newest issue per Image series since 2018.
---
## Reverse proxy tips
## 🔍 Search
* **Domain at root**: set `SERVER_BASE=https://comics.example.com`
* **Domain + subpath**: set `SERVER_BASE=https://example.com` and `URL_PREFIX=/comics`
ComicOPDS provides powerful search capabilities:
### Search Technology
- **SQLite FTS5**: Full-text search when available
- **Fallback**: LIKE queries when FTS5 unavailable
> Check `/debug/fts`, which returns `{ "fts5": true/false }` indicating whether SQLite FTS5 is enabled.
### Searchable Fields
- `series` - Comic series name
- `title` - Individual issue title
- `publisher` - Publishing company
- `year` - Publication year
- `writer` - Writer(s)
- `penciller` - Artist(s)
- `genre` - Comic genre/category
- `characters` - Featured characters
- `tags` - Custom tags
- `format` - TPB, Main Series, Annual, One-Shot etc.
### Search Tips
- Use quotes for exact phrases: `"Dark Knight"`
- Combine terms: `batman joker`
- Use wildcards: `bat*` (when FTS5 available)
---
## Troubleshooting
## 📱 Clients
* **No comics show up**: confirm your host path is mounted read-only at `/library` and files end with `.cbz`.
* **Missing covers**: first request to an entry generates the cover. Check `/data/thumbs/` is writable.
* **Auth prompts repeatedly**: verify browser/app supports Basic Auth and credentials match `OPDS_BASIC_USER/PASS`.
* **Wrong links behind proxy**: ensure `SERVER_BASE` and (if needed) `URL_PREFIX` are set correctly.
**Supported Clients**
| App | Downloads | Search | Streaming |
| --------------------------- | -- | -- | -- |
| KyBook 3 (iOS) | ✔️ | ✔️ | ❌ |
| Cantook (iOS) | ✔️ | ❌ | ❌ |
| Panels (iOS) | ✔️ |✔️ |✔️ |
| Marvin 3 (iOS) | ✔️ | ❌ | ❌ |
| Chunky (iOS) | ✔️ | ❌ | ❌ |
### Panels for iOS
1. Open Panels → Library → Connect Service → OPDS
2. **URL**: Your OPDS root (e.g., `https://comics.example.com/`)
3. **Username/Password**: If you enabled Basic Auth
4. Panels will display covers and use your folder structure for browsing
### Client-Specific Notes
- Some clients work better with smaller `PAGE_SIZE` (e.g., 25 instead of 50)
- Page streaming (PSE 1.2) requires client support
- Thumbnail quality may vary between clients
---
## Development
## 🛠️ Troubleshooting
Install deps locally:
### Common Issues
#### No Comics Appearing
- **Check mount**: Ensure your comics folder is mounted at `/library`
- **File format**: Only `.cbz` files are supported
- **Metadata**: Ensure CBZ files contain `ComicInfo.xml`
- **Permissions**: Verify read permissions on comic files
#### Missing Thumbnails
- **First load**: Thumbnails generate on first request
- **Check permissions**: Ensure `/data/thumbs` is writable
- **View errors**: Check error log via dashboard or `/data/thumbs_errors.log`
#### Authentication Problems
- **Verify credentials**: Check `OPDS_BASIC_USER` and `OPDS_BASIC_PASS`
- **Client support**: Ensure your client supports HTTP Basic Auth
- **Disable auth**: Set `DISABLE_AUTH=true` for testing
#### Wrong Links/URLs
- **Behind proxy**: Set `SERVER_BASE` and `URL_PREFIX` correctly
- **Protocol mismatch**: Ensure HTTP/HTTPS consistency
### Debug Commands
```bash
pip install fastapi uvicorn jinja2 pillow
uvicorn app.main:app --reload --proxy-headers --forwarded-allow-ips="*"
# View container logs
docker compose logs -f comicopds
# Check container health
docker compose ps
# Inspect configuration
docker compose exec comicopds env | grep -E "(SERVER_BASE|URL_PREFIX|CONTENT_BASE_DIR)"
# Test internal connectivity
docker compose exec comicopds wget -qO- http://localhost:8080/healthz
# Check FTS5 availability
curl -u admin:password http://localhost:8382/debug/fts
```
### Log Files
- **Application logs**: `docker compose logs -f`
- **Thumbnail errors**: `/data/thumbs_errors.log`
### Performance Tuning
- **Large libraries**: Disable `AUTO_INDEX_ON_START`
- **Slow thumbnails**: Increase `THUMB_WORKERS`
- **Memory usage**: Reduce `PAGE_SIZE`
- **Network issues**: Check `SERVER_BASE` configuration
---
## 📄 License
MIT License use freely, modify, and contribute.
```
Copyright (c) 2024 Frederik Baerentsen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
---
## 🔗 Links
- **Repository**: [Gitea](https://gitea.baerentsen.space/FrederikBaerentsen/ComicOPDS)
- **OPDS Specification**: [OPDS 1.2](https://specs.opds.io/opds-1.2)
- **OPDS Page Streaming Extension: [OPDS PSE 1.2](https://anansi-project.github.io/docs/opds-pse/specs/v1.2)
- **Buy Me a Coffee**: [frederikb](https://www.buymeacoffee.com/frederikb)
---
*Made with ❤️ for comic book enthusiasts*