Skip to content
Merged
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
21 changes: 21 additions & 0 deletions .claude/rules/komga.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
paths:
- "src/Maki.Api/Controllers/Komga*.cs"
- "src/Maki.Api/Komga/**"
- "src/Maki.Core/Komga/**"
- "tests/Maki.Api.Tests/Komga*.cs"
---

# Komga-compatible API

`opds.md` and `auth.md` cover the token this rides on; the rules there still apply.

- **It lives under `/komga/api/...`, not `/api/v1`.** Komga clients take a server address and append `/api/v1/...` themselves, which would collide with Maki's own routes. The address a user enters is `http://host:port/komga`. The catch-all `Unsupported` action answers 404 for Komga routes Maki does not serve, or the SPA fallback would hand clients `index.html` with a 200.
- **The credential is the user's OPDS token, and both `opds.enabled` and `komga.enabled` must be on.** `KomgaAuthenticator` accepts it three ways: `X-API-Key` (Komga's API keys), HTTP Basic with the token as the password (username ignored), and the `Maki.Komga` cookie it issues after either. A `Full` API key does not open it. `OpdsAccess.Komga` carries the switch, so `OpdsAccessService.EvictUser` covers it.
- **401, never OPDS's 404.** The Mihon extension only sends Basic after a 401 (OkHttp's authenticator), and a 404 reads as "no server here".
- **The cookie is load-bearing.** The Mihon Komga tracker shares the source's cookie jar and sends no credential of its own, so without it two-way sync fails with 401. It holds the token under data protection (30 days), so revocation, rotation and either switch apply to it at once. It never authenticates a POST: that is the one unsafe method another app on the same site can send without a CORS preflight.
- **Page fetches are not progress here**, unlike OPDS. Komga clients that track reading say so: `PATCH books/{id}/read-progress` (pages are 1-based), and the tracker's `PUT v2/series/{id}/read-progress/tachiyomi`. Both go through `ReaderService`, so incognito, stats, scrobbling and Kavita push-back behave as for the built-in reader.
- **A tracker update of one chapter is a read, a jump is a silent mark.** `ApplyTrackerProgressAsync` records a single newly crossed `numberSort` group through `SaveProgressAsync`, and marks several through `MarkReadAsync` (`BulkMarked`), so binding the tracker to a series read elsewhere does not land in Rewind as a day's reading. It only marks read, never unread, as Komga does.
- **`numberSort` is what Mihon uses as the chapter number and syncs on** (`KomgaNumbering`). It must never decrease along `ChapterOrder`: unnumbered chapters continue the sequence, and a volume's chapter 0 sorted after higher numbers repeats the previous value. Books sharing one (the same chapter in two languages) count as one group, read when any of them is.
- **Every DTO field is always written, nulls included.** The Mihon extension and tracker use kotlinx.serialization, which rejects a missing key even for a nullable property. Don't add `JsonIgnore(WhenWritingNull)` to anything in `KomgaDtos.cs`.
- **`?convert=jpeg|png` is honoured on page fetches**, because it is how a Komga client copes with a media type it cannot decode (the Komga web reader asks for JPEG when the browser lacks WebP, AVIF or JXL). The page's bytes decide, not its name: one already in that format goes out untouched, anything else is re-encoded at full size through `ImageCodec.Transcode` under `ImageWorkGate`. Any other value is a 400 as on Komga; a page no permitted loader reads (BMP) is a 415. The ETag carries the format, or a cached original would answer a conversion request. The book thumbnail's first-page fallback always asks for JPEG, since Komga thumbnails are JPEG.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

See [README.md](README.md) for overview. Before a release, or when asked to smoke-test one, work through [docs/release-checklist.md](docs/release-checklist.md).

Subsystem gotchas live in `.claude/rules/*.md` and load automatically when you touch matching files: `auth.md`, `series-matching.md`, `reader-progress.md`, `recommendations.md`, `opds.md`, `downloads.md`, `stats-notifications.md`, `i18n.md`, `infra.md`, `design-system.md`, `pwa.md`, `storybook.md`. `distribution/CLAUDE.md` and `src/Maki.Sources/CLAUDE.md` are separate nested files, same deal.
Subsystem gotchas live in `.claude/rules/*.md` and load automatically when you touch matching files: `auth.md`, `series-matching.md`, `reader-progress.md`, `recommendations.md`, `opds.md`, `komga.md`, `downloads.md`, `stats-notifications.md`, `i18n.md`, `infra.md`, `design-system.md`, `pwa.md`, `storybook.md`. `distribution/CLAUDE.md` and `src/Maki.Sources/CLAUDE.md` are separate nested files, same deal.

**Adding a file to one of those subsystems? Check its rule file's `paths` frontmatter.** The globs are prefix/suffix wildcards (e.g. `Services/*Download*.cs`), not a static list — most naturally-named new files match automatically. If a new file doesn't fit any existing pattern (unusual name, new area within the subsystem), add its path to that rule file's frontmatter so future sessions actually load the gotchas instead of silently missing them.

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@

Maki watches manga sites for new chapters, downloads the pages and writes CBZ files with
ComicInfo.xml metadata. [Kavita](https://www.kavitareader.com) and most comic readers understand
that format, so the same folder works everywhere. You can also read in Maki's own reader, or over
OPDS in Panels, Chunky, KOReader and Mihon.
that format, so the same folder works everywhere. You can also read in Maki's own reader, over
OPDS in Panels, Chunky, KOReader and Mihon, or in apps that speak Komga's API.

> [!NOTE]
> Maki is almost entirely AI-slop-built, developed with Anthropic's latest Claude models. It is
Expand Down
6 changes: 6 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@
- **OPDS server.** OPDS 1.2 with page streaming (OPDS-PSE), so Panels, Chunky, KOReader, and
Mihon read straight from Maki. Off by default. Each user's feed URL carries its own rotatable
token, and pages streamed this way count as read.
- **Komga-compatible API.** Apps that only connect to a Komga server, like the Mihon Komga
extension and Tachimanga, can use Maki instead. Point them at `http://your-host:8990/komga` and
use your OPDS token as the API key (or as the password, with any username). Reading progress
syncs both ways, including through Mihon's Komga tracker. A client that cannot decode a page's
format (an AVIF or WebP page) can ask for it as JPEG or PNG, as it would from Komga. Off by
default, switched on in the OPDS settings.
- **Install as an app and read offline.** Maki installs to a phone or desktop home screen. Save
chapters or whole series to the device, or let it keep the next few unread chapters of what
you're reading. Library and series pages you have visited still open offline, and progress read
Expand Down
4 changes: 4 additions & 0 deletions docs/release-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,10 @@ Use a scratch series here: reaching a chapter's last page marks it read and adds
- [ ] **7.3** Notifications: add a webhook connection (any request bin) and **Test** delivers.
- [ ] **7.4** OPDS: enable it, **Copy** the feed URL, and fetch the URL with `curl`. The feed lists
the library. The OPDS token is refused on `/api/v1`.
- [ ] **7.4a** Komga API: switch it on in the OPDS card and run
`curl -H "X-API-Key: <token>" http://<host>/komga/api/v1/series`. It lists the library; without
the header it answers 401. In Mihon, add the Komga extension with that address and key, read a
chapter with the Komga tracker on, and the chapter shows as read in Maki.
- [ ] **7.5** Backup: create one and download it. The zip has `maki.db`, `config.json` and
`manifest.json`, and no `dataprotection-keys`.
- [ ] **7.6** Health page: run a scan. It completes and lists the library files.
Expand Down
4 changes: 2 additions & 2 deletions frontend/.storybook/mocks/settings/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,8 +585,8 @@ export const twoFactorOff = {

export const twoFactorOn = { ...twoFactorOff, enabled: true, hasAuthenticator: true, recoveryCodesLeft: 8 }

export const opdsOff: OpdsSettings = { enabled: false, trackProgress: true, hasToken: false, tokenPrefix: null, feedUrl: null }
export const opdsOn: OpdsSettings = { enabled: true, trackProgress: true, hasToken: true, tokenPrefix: 'a91f3c', feedUrl: null }
export const opdsOff: OpdsSettings = { enabled: false, trackProgress: true, hasToken: false, tokenPrefix: null, feedUrl: null, komga: false }
export const opdsOn: OpdsSettings = { enabled: true, trackProgress: true, hasToken: true, tokenPrefix: 'a91f3c', feedUrl: null, komga: false }

export const inboxPrefs: InboxPrefs = {
types: {
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/api/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2657,6 +2657,8 @@ export interface OpdsSettings {
* so a plain GET always returns null here and the URL cannot be shown a second time.
*/
feedUrl: string | null
/** The Komga-compatible API under `/komga`, opened by the same token. Only answers while `enabled` is on too. */
komga: boolean
}

export function useOpdsSettings() {
Expand All @@ -2670,7 +2672,7 @@ export function useSaveOpdsSettings() {
const queryClient = useQueryClient()
return useMutation({
// `password` is only needed when the save mints the token (first enable); the server decides.
mutationFn: (settings: { enabled: boolean; trackProgress: boolean; password?: string }) =>
mutationFn: (settings: { enabled: boolean; trackProgress: boolean; komga?: boolean; password?: string }) =>
api<OpdsSettings>('/settings/opds', { method: 'PUT', body: JSON.stringify(settings) }),
onSuccess: (data) => {
// The PUT mints the token on first enable, so seed the cache from the response rather
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/pages/settings/ReadingCards.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,18 @@ export const OpdsEnabled: Story = {
},
}

export const OpdsKomgaEnabled: Story = {
render: () => <OpdsSection />,
parameters: {
msw: settingsMsw(http.get(api('/settings/opds'), () => HttpResponse.json({ ...opdsOn, komga: true }))),
},
play: async ({ canvas }) => {
await waitFor(() => expect(canvas.getByRole('switch', { name: /^Komga-compatible API/ })).toBeChecked())
await expect(await canvas.findByText(/\/komga$/)).toBeVisible()
await expect(await canvas.findByText(/last part of your feed URL/)).toBeVisible()
},
}

export const OpdsDisabled: Story = {
render: () => <OpdsSection />,
parameters: { msw: settingsMsw(http.get(api('/settings/opds'), () => HttpResponse.json(opdsOff))) },
Expand Down
87 changes: 81 additions & 6 deletions frontend/src/pages/settings/ReadingCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,17 +194,24 @@ export function OpdsSection() {

const enabled = opds?.enabled ?? false
const trackProgress = opds?.trackProgress ?? true
const komga = opds?.komga ?? false
// The server emits a relative path on purpose (it can't know the host behind a reverse proxy),
// so the address the user actually pastes is assembled here.
const feedUrl = revealedPath ? `${window.location.origin}${revealedPath}` : null
// Komga apps take the same token on its own, as their API key.
const token = revealedPath ? revealedPath.split('/').pop() || null : null
const komgaAddress = `${window.location.origin}/komga`

const closePasswordModals = () => {
setRotateModalOpen(false)
setEnableModalOpen(false)
setPassword('')
}

const saveWith = (patch: Partial<{ enabled: boolean; trackProgress: boolean }>, confirmPassword?: string) =>
const saveWith = (
patch: Partial<{ enabled: boolean; trackProgress: boolean; komga: boolean }>,
confirmPassword?: string,
) =>
save.mutate(
{ enabled, trackProgress, ...patch, password: confirmPassword || undefined },
{
Expand All @@ -217,10 +224,10 @@ export function OpdsSection() {
},
)

const copy = () => {
if (!feedUrl) return
void copyFeedUrl(feedUrl).then((ok) => {
if (ok) notifications.show({ message: now`Feed URL copied`, color: 'var(--ok)' })
const copy = (value: string | null, message: string) => {
if (!value) return
void copyFeedUrl(value).then((ok) => {
if (ok) notifications.show({ message, color: 'var(--ok)' })
})
}

Expand Down Expand Up @@ -264,7 +271,11 @@ export function OpdsSection() {
<Group gap="xs" wrap="nowrap">
<Code style={{ overflowWrap: 'anywhere' }}>{feedUrl}</Code>
<Tooltip label={t`Copy feed URL`}>
<ActionIcon variant="light" onClick={copy} aria-label={t`Copy feed URL`}>
<ActionIcon
variant="light"
onClick={() => copy(feedUrl, now`Feed URL copied`)}
aria-label={t`Copy feed URL`}
>
<IconCopy size={16} />
</ActionIcon>
</Tooltip>
Expand Down Expand Up @@ -314,6 +325,70 @@ export function OpdsSection() {
</Text>
</div>
)}

{enabled && (
<div>
<Switch
label={t`Komga-compatible API`}
checked={komga}
onChange={(e) => saveWith({ komga: e.currentTarget.checked })}
/>
<Text size="xs" c="var(--ink-3)" mt={4}>
<Trans>
For apps that only connect to a Komga server, like the Mihon Komga extension and
Tachimanga. Reading progress syncs both ways; in Mihon, turn on the Komga tracker.
</Trans>
</Text>
{komga && (
<Stack gap={6} mt="xs">
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={500}>
<Trans>Server address</Trans>
</Text>
<Code style={{ overflowWrap: 'anywhere' }}>{komgaAddress}</Code>
<Tooltip label={t`Copy server address`}>
<ActionIcon
variant="light"
onClick={() => copy(komgaAddress, now`Server address copied`)}
aria-label={t`Copy server address`}
>
<IconCopy size={16} />
</ActionIcon>
</Tooltip>
</Group>
{token && (
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={500}>
<Trans>API key</Trans>
</Text>
<Code style={{ overflowWrap: 'anywhere' }}>{token}</Code>
<Tooltip label={t`Copy API key`}>
<ActionIcon
variant="light"
onClick={() => copy(token, now`API key copied`)}
aria-label={t`Copy API key`}
>
<IconCopy size={16} />
</ActionIcon>
</Tooltip>
</Group>
)}
<Text size="xs" c="var(--ink-3)">
{token ? (
<Trans>
Enter the API key in the app, or any username with the API key as the password.
</Trans>
) : (
<Trans>
The API key is the last part of your feed URL, after /opds/. Enter it in the app,
or use any username with the API key as the password.
</Trans>
)}
</Text>
</Stack>
)}
</div>
)}
</Stack>

<Modal opened={enableModalOpen} onClose={closePasswordModals} title={t`Enable the OPDS catalogue`} centered>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/settings/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ export const SETTINGS_ENTRIES: SettingsEntry[] = [
title: msg`OPDS catalogue`,
permission: 'UseOpds',
keywords: msg({
message: `feed url, catalogue, catalog, panels, chunky, koreader, tachiyomi, mihon, streaming, token, track progress`,
message: `feed url, catalogue, catalog, panels, chunky, koreader, tachiyomi, mihon, streaming, token, track progress, komga, tachimanga, api key`,
comment: `Search terms for the settings command palette, not prose. Translate each term as the word someone would actually type in this language, keep them comma-separated, and add or drop terms freely: the list does not have to match English item for item.`,
}),
},
Expand Down
4 changes: 4 additions & 0 deletions frontend/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export default defineConfig((env) =>
],
test: {
name: 'storybook',
// On CI runners MSW intermittently misses a request (it reaches Vite's proxy instead), and
// whichever story was running fails; a different handful each run. A broken story still
// fails every attempt.
retry: process.env.CI ? 2 : 0,
// A fresh iframe per file reloads the whole app 202 times: 97% of a CI run, and each
// closing iframe churns MSW's service worker clients. The preview resets state per story.
isolate: false,
Expand Down
Loading
Loading