diff --git a/.dockerignore b/.dockerignore index ab8a28c7..196f09bc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,9 @@ target/ **/*.rs.bk +# Docker local data (mounted volumes, caches) +docker/ + # Frontend build artifacts and dependencies web/node_modules/ web/dist/ @@ -11,6 +14,24 @@ web/.vite/ docs/node_modules/ docs/build/ +# Screenshots build artifacts and dependencies +screenshots/node_modules/ +screenshots/dist/ +screenshots/.vite/ +screenshots/fixtures/ +screenshots/output/ + +# Plugins build artifacts and dependencies +plugins/sdk-typescript/node_modules/ +plugins/sdk-typescript/dist/ +plugins/sdk-typescript/.vite/ +plugins/metadata-mangabaka/node_modules/ +plugins/metadata-mangabaka/dist/ +plugins/metadata-mangabaka/.vite/ +plugins/metadata-echo/node_modules/ +plugins/metadata-echo/dist/ +plugins/metadata-echo/.vite/ + # Database files *.db *.db-shm diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index da3a463b..54a1b986 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -115,6 +115,8 @@ jobs: uses: actions/setup-node@v4 with: node-version: "22" + # Note: We remove package-lock.json and use npm install instead of npm ci + # because Biome updates frequently and we want to use the latest compatible version - name: Install dependencies working-directory: web run: | @@ -136,10 +138,60 @@ jobs: path: web/dist retention-days: 1 + # Run plugin checks (lint, typecheck, tests) + plugins: + name: Plugins (${{ matrix.plugin }}) + # SELF-HOSTED: Change to ubuntu-latest for GitHub runners and remove container + runs-on: arc-runner-codex + container: ubuntu:22.04 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + plugin: + - sdk-typescript + - metadata-echo + - metadata-mangabaka + steps: + - name: Install base dependencies + run: | + apt-get update + apt-get install -y git curl + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + # SDK must be built first for other plugins to use + - name: Build SDK (dependency) + if: matrix.plugin != 'sdk-typescript' + working-directory: plugins/sdk-typescript + run: | + rm -rf node_modules package-lock.json + npm install + npm run build + - name: Install dependencies + working-directory: plugins/${{ matrix.plugin }} + run: | + rm -rf node_modules package-lock.json + npm install + - name: Lint + working-directory: plugins/${{ matrix.plugin }} + run: npm run lint + - name: Typecheck + working-directory: plugins/${{ matrix.plugin }} + run: npx tsc --noEmit + - name: Test + working-directory: plugins/${{ matrix.plugin }} + run: npm run test + - name: Build + working-directory: plugins/${{ matrix.plugin }} + run: npm run build + # Run 'dist plan' (or host) to determine what tasks we need to do plan: name: Plan - needs: [test, lint, frontend] + needs: [test, lint, frontend, plugins] # SELF-HOSTED: Change to ubuntu-22.04 for GitHub runners and remove container runs-on: arc-runner-codex container: ubuntu:22.04 @@ -366,7 +418,7 @@ jobs: # Uses Dockerfile.cross which cross-compiles ARM on x86 (no QEMU emulation) docker: name: Docker - needs: [test, lint, frontend] + needs: [test, lint, frontend, plugins] # Only build Docker on main branch or release tags if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') }} # SELF-HOSTED: Change to ubuntu-latest for GitHub runners @@ -516,10 +568,101 @@ jobs: gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + # Publish SDK to npm (only for releases) + publish-sdk: + name: Publish SDK + needs: + - plan + - host + if: ${{ always() && needs.host.result == 'success' }} + # SELF-HOSTED: Change to ubuntu-latest for GitHub runners and remove container + runs-on: arc-runner-codex + container: ubuntu:22.04 + steps: + - name: Install base dependencies + run: | + apt-get update + apt-get install -y git curl + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + # Note: We remove package-lock.json and use npm install instead of npm ci + # because Biome updates frequently and we want to use the latest compatible version + - name: Install dependencies and build + working-directory: plugins/sdk-typescript + run: | + rm -rf node_modules package-lock.json + npm install + npm run build + - name: Publish to npm + working-directory: plugins/sdk-typescript + run: npm publish --access public --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # Publish plugins to npm (only for releases, after SDK is published) + publish-plugins: + name: Publish Plugins + needs: + - plan + - publish-sdk + if: ${{ always() && needs.publish-sdk.result == 'success' }} + # SELF-HOSTED: Change to ubuntu-latest for GitHub runners and remove container + runs-on: arc-runner-codex + container: ubuntu:22.04 + strategy: + fail-fast: false + matrix: + plugin: + - metadata-echo + - metadata-mangabaka + steps: + - name: Install base dependencies + run: | + apt-get update + apt-get install -y git curl jq + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + # Update SDK dependency from file: to published npm version before install + - name: Update SDK dependency for publish + working-directory: plugins/${{ matrix.plugin }} + run: | + SDK_VERSION=$(jq -r .version ../sdk-typescript/package.json) + echo "Updating @codex/plugin-sdk dependency to ^$SDK_VERSION" + jq --arg v "^$SDK_VERSION" '.dependencies["@codex/plugin-sdk"] = $v' package.json > tmp.json + mv tmp.json package.json + cat package.json | grep -A2 '"dependencies"' + - name: Install plugin dependencies + working-directory: plugins/${{ matrix.plugin }} + run: | + rm -rf node_modules package-lock.json + npm install + - name: Build plugin + working-directory: plugins/${{ matrix.plugin }} + run: npm run build + - name: Publish to npm + working-directory: plugins/${{ matrix.plugin }} + run: npm publish --access public --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + announce: needs: - plan - host + - publish-sdk + - publish-plugins # use "always() && ..." to allow us to wait for all publish jobs while # still allowing individual publish jobs to skip themselves (for prereleases). # "host" however must run to completion, no skipping allowed! diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 789f742e..de6e12d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,10 +116,60 @@ jobs: working-directory: web run: npm run build + # Run plugin checks (lint, typecheck, tests) + plugins: + name: Plugins (${{ matrix.plugin }}) + # SELF-HOSTED: Change to ubuntu-latest for GitHub runners and remove container + runs-on: arc-runner-codex + container: ubuntu:22.04 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + plugin: + - sdk-typescript + - metadata-echo + - metadata-mangabaka + steps: + - name: Install base dependencies + run: | + apt-get update + apt-get install -y git curl + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + # SDK must be built first for other plugins to use + - name: Build SDK (dependency) + if: matrix.plugin != 'sdk-typescript' + working-directory: plugins/sdk-typescript + run: | + rm -rf node_modules package-lock.json + npm install + npm run build + - name: Install dependencies + working-directory: plugins/${{ matrix.plugin }} + run: | + rm -rf node_modules package-lock.json + npm install + - name: Lint + working-directory: plugins/${{ matrix.plugin }} + run: npm run lint + - name: Typecheck + working-directory: plugins/${{ matrix.plugin }} + run: npx tsc --noEmit + - name: Test + working-directory: plugins/${{ matrix.plugin }} + run: npm run test + - name: Build + working-directory: plugins/${{ matrix.plugin }} + run: npm run build + # Build and push Docker image with PR tag docker: name: Docker - needs: [test, lint, frontend] + needs: [test, lint, frontend, plugins] runs-on: arc-runner-codex timeout-minutes: 240 steps: diff --git a/.gitignore b/.gitignore index e422af8e..7bdf73fa 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ *.db-shm *.db-wal *.log + +plugins/**/openapi.json \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index a528fdd3..f15e6ff3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -767,6 +767,7 @@ dependencies = [ "quick-xml", "rand 0.8.5", "regex", + "reqwest", "resvg", "rust-embed", "sea-orm", @@ -784,7 +785,7 @@ dependencies = [ "tokio-stream", "tokio-util", "tower 0.4.13", - "tower-http", + "tower-http 0.5.2", "tracing", "tracing-appender", "tracing-subscriber", @@ -1825,20 +1826,45 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.5", +] + [[package]] name = "hyper-util" version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ + "base64 0.22.1", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -2081,6 +2107,22 @@ dependencies = [ "syn 2.0.112", ] +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -2490,6 +2532,12 @@ dependencies = [ "weezl", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "lzma-rs" version = "0.3.0" @@ -3309,6 +3357,61 @@ dependencies = [ "serde", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.42" @@ -3538,6 +3641,44 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower 0.5.2", + "tower-http 0.6.8", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.5", +] + [[package]] name = "resvg" version = "0.44.0" @@ -3705,6 +3846,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3748,6 +3895,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -4592,6 +4740,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -5006,6 +5157,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower 0.5.2", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -5507,6 +5676,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" diff --git a/Cargo.toml b/Cargo.toml index 67c8bccb..ede7a41e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -122,6 +122,9 @@ lettre = { version = "0.11", default-features = false, features = [ "builder", ] } +# HTTP Client (for plugin cover downloads) +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } + # API Documentation utoipa = { version = "5.0", features = [ "axum_extras", diff --git a/Dockerfile b/Dockerfile index e5a6146d..8f03a3b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,26 +23,29 @@ FROM rust:1.92-alpine AS chef RUN apk add --no-cache \ musl-dev \ build-base \ - clang + clang \ + mold RUN cargo install cargo-chef WORKDIR /app +# Compiler flags: +# - target-feature=-crt-static: Disable static linking for PDFium dlopen() support +# - linker=clang + fuse-ld=mold: Use mold linker for faster linking +ENV RUSTFLAGS="-C target-feature=-crt-static -C linker=clang -C link-arg=-fuse-ld=mold" + # Stage 3: Prepare recipe FROM chef AS planner # Only copy Rust-related files to avoid cache invalidation from frontend changes COPY Cargo.toml Cargo.lock ./ -COPY src/ ./src/ +COPY assets/ ./assets/ COPY migration/ ./migration/ +COPY src/ ./src/ RUN cargo chef prepare --recipe-path recipe.json # Stage 4: Build dependencies (cached layer) FROM chef AS builder COPY --from=planner /app/recipe.json recipe.json -# Disable static linking to enable dlopen() for PDFium dynamic loading -# This is required because musl's static linking doesn't support dlopen() -ENV RUSTFLAGS="-C target-feature=-crt-static" - # Build dependencies (this layer is cached) # Use BuildKit cache mounts to persist Cargo registry/git between builds RUN --mount=type=cache,target=/usr/local/cargo/registry \ @@ -74,7 +77,14 @@ FROM alpine:latest AS runtime # - ca-certificates for HTTPS # - su-exec for user switching # - libstdc++ and libgcc for PDFium -RUN apk add --no-cache ca-certificates su-exec libstdc++ libgcc curl wget +# - nodejs and npm for TypeScript/JavaScript plugins +RUN apk add --no-cache ca-certificates su-exec libstdc++ libgcc curl wget nodejs npm + +# Install uv (fast Python package manager) for Python plugins +# uv provides 'uvx' command for running Python packages without installation +RUN wget -qO- https://astral.sh/uv/install.sh | sh && \ + mv /root/.local/bin/uv /usr/local/bin/uv && \ + mv /root/.local/bin/uvx /usr/local/bin/uvx # Install PDFium library for PDF page rendering # This enables rendering of text-only and vector PDF pages diff --git a/Dockerfile.cross b/Dockerfile.cross index 919f200f..0abe5f6b 100644 --- a/Dockerfile.cross +++ b/Dockerfile.cross @@ -136,7 +136,14 @@ FROM alpine:latest AS runtime # - ca-certificates for HTTPS # - su-exec for user switching # - libstdc++ and libgcc for PDFium -RUN apk add --no-cache ca-certificates su-exec libstdc++ libgcc curl wget +# - nodejs and npm for TypeScript/JavaScript plugins +RUN apk add --no-cache ca-certificates su-exec libstdc++ libgcc curl wget nodejs npm + +# Install uv (fast Python package manager) for Python plugins +# uv provides 'uvx' command for running Python packages without installation +RUN wget -qO- https://astral.sh/uv/install.sh | sh && \ + mv /root/.local/bin/uv /usr/local/bin/uv && \ + mv /root/.local/bin/uvx /usr/local/bin/uvx # Install PDFium library for PDF page rendering # This enables rendering of text-only and vector PDF pages diff --git a/Dockerfile.dev b/Dockerfile.dev index 5d5c10bc..09da3693 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -3,6 +3,7 @@ FROM rust:1.92-alpine # Install development dependencies +# Includes Node.js and npm for TypeScript/JavaScript plugin development RUN apk add --no-cache \ pkgconf \ openssl-dev \ @@ -14,7 +15,15 @@ RUN apk add --no-cache \ libstdc++ \ libgcc \ clang \ - mold + mold \ + nodejs \ + npm + +# Install uv (fast Python package manager) for Python plugin development +# uv provides 'uvx' command for running Python packages without installation +RUN wget -qO- https://astral.sh/uv/install.sh | sh && \ + mv /root/.local/bin/uv /usr/local/bin/uv && \ + mv /root/.local/bin/uvx /usr/local/bin/uvx # Install PDFium library for PDF page rendering # This enables rendering of text-only and vector PDF pages in development diff --git a/Makefile b/Makefile index 0b715968..8205c8bf 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Makefile for Codex development and deployment -.PHONY: help build test run dev-* test-* docs-* docker-* db-* screenshots screenshots-* +.PHONY: help build test run dev-* test-* docs-* docker-* db-* screenshots screenshots-* plugins-* # Colors for output BLUE := \033[0;34m @@ -234,9 +234,86 @@ frontend-fixtures: ## Generate mock fixture files (CBZ, EPUB, PDF) frontend-lint: ## Run frontend lint cd web && npm run lint -frontend-lint-fix: ## Run frontend lint +frontend-lint-fix: ## Run frontend lint with auto-fix cd web && npm run lint -- --write +# ============================================================================= +# Plugin Development +# ============================================================================= + +PLUGIN_DIRS := sdk-typescript metadata-echo metadata-mangabaka + +plugins-install: ## Install dependencies for all plugins + @echo "$(BLUE)Installing plugin dependencies...$(NC)" + @for dir in $(PLUGIN_DIRS); do \ + echo "$(YELLOW)Installing $$dir...$(NC)"; \ + (cd plugins/$$dir && npm install); \ + done + @echo "$(GREEN)All plugin dependencies installed!$(NC)" + +plugins-build: ## Build all plugins + @echo "$(BLUE)Building plugins...$(NC)" + @echo "$(YELLOW)Building sdk-typescript...$(NC)" + @cd plugins/sdk-typescript && npm run build + @echo "$(YELLOW)Building metadata-echo...$(NC)" + @cd plugins/metadata-echo && npm run build + @echo "$(YELLOW)Building metadata-mangabaka...$(NC)" + @cd plugins/metadata-mangabaka && npm run build + @echo "$(GREEN)All plugins built!$(NC)" + +plugins-lint: ## Run lint on all plugins + @echo "$(BLUE)Linting plugins...$(NC)" + @for dir in $(PLUGIN_DIRS); do \ + echo "$(YELLOW)Linting $$dir...$(NC)"; \ + (cd plugins/$$dir && npm run lint) || exit 1; \ + done + @echo "$(GREEN)All plugins linted!$(NC)" + +plugins-lint-fix: ## Run lint with auto-fix on all plugins + @echo "$(BLUE)Fixing lint issues in plugins...$(NC)" + @for dir in $(PLUGIN_DIRS); do \ + echo "$(YELLOW)Fixing $$dir...$(NC)"; \ + (cd plugins/$$dir && npm run lint:fix) || exit 1; \ + done + @echo "$(GREEN)All plugin lint issues fixed!$(NC)" + +plugins-test: ## Run tests on all plugins + @echo "$(BLUE)Testing plugins...$(NC)" + @for dir in $(PLUGIN_DIRS); do \ + echo "$(YELLOW)Testing $$dir...$(NC)"; \ + (cd plugins/$$dir && npm run test) || exit 1; \ + done + @echo "$(GREEN)All plugin tests passed!$(NC)" + +plugins-typecheck: ## Run typecheck on all plugins + @echo "$(BLUE)Typechecking plugins...$(NC)" + @cd plugins/sdk-typescript && npm run build + @for dir in $(PLUGIN_DIRS); do \ + echo "$(YELLOW)Typechecking $$dir...$(NC)"; \ + (cd plugins/$$dir && npx tsc --noEmit) || exit 1; \ + done + @echo "$(GREEN)All plugins typechecked!$(NC)" + +plugins-check: ## Run lint, typecheck, and tests on all plugins + @$(MAKE) plugins-lint + @$(MAKE) plugins-typecheck + @$(MAKE) plugins-test + @echo "$(GREEN)All plugin checks passed!$(NC)" + +plugins-check-fix: ## Run lint:fix, typecheck, and tests on all plugins + @$(MAKE) plugins-lint-fix + @$(MAKE) plugins-typecheck + @$(MAKE) plugins-test + @echo "$(GREEN)All plugin checks passed!$(NC)" + +plugins-clean: ## Clean build artifacts from all plugins + @echo "$(BLUE)Cleaning plugins...$(NC)" + @for dir in $(PLUGIN_DIRS); do \ + echo "$(YELLOW)Cleaning $$dir...$(NC)"; \ + (cd plugins/$$dir && npm run clean) || exit 1; \ + done + @echo "$(GREEN)All plugins cleaned!$(NC)" + # ============================================================================= # Setup # ============================================================================= @@ -354,6 +431,8 @@ clean-all: clean clean-docker ## Clean everything (artifacts + Docker + volumes) # 3. Find screenshots in screenshots/output/ screenshots: ## Run full screenshot workflow (start, capture, stop) + @echo "$(BLUE)Building plugins...$(NC)" + @$(MAKE) plugins-build @echo "$(BLUE)Starting screenshot automation...$(NC)" @$(MAKE) screenshots-up @echo "$(YELLOW)Waiting for services to be ready...$(NC)" @@ -362,6 +441,11 @@ screenshots: ## Run full screenshot workflow (start, capture, stop) @$(MAKE) screenshots-down @echo "$(GREEN)Screenshots complete! Check screenshots/output/$(NC)" +screenshots-fresh: ## Run full screenshot workflow with fresh plugins + @$(MAKE) screenshots-clean + @$(MAKE) screenshots-down + @$(MAKE) screenshots + screenshots-up: ## Start screenshot environment docker compose --profile screenshots up -d --build @echo "$(GREEN)Screenshot environment started$(NC)" @@ -385,7 +469,7 @@ screenshots-shell: ## Open shell in Playwright container docker compose --profile screenshots exec playwright sh screenshots-clean: ## Remove generated screenshots - rm -f screenshots/output/*.png screenshots/output/*.jpg + rm -rf screenshots/output/* @echo "$(GREEN)Screenshots cleaned$(NC)" screenshots-move-to-docs: ## Move screenshots to docs/screenshots @@ -441,12 +525,31 @@ release-prepare: ## Prepare a release (usage: make release-prepare VERSION=1.0.0 @# Update Cargo.toml version @sed -i.bak 's/^version = ".*"/version = "$(VERSION)"/' Cargo.toml && rm Cargo.toml.bak @echo "$(GREEN)✓$(NC) Cargo.toml version set to $(VERSION)" + @# Update web/package.json version @cd web && npm version $(VERSION) --no-git-tag-version --allow-same-version >/dev/null 2>&1 @echo "$(GREEN)✓$(NC) web/package.json version set to $(VERSION)" + + @# Update plugins/sdk-typescript/package.json version + @cd plugins/sdk-typescript && npm version $(VERSION) --no-git-tag-version --allow-same-version >/dev/null 2>&1 + @echo "$(GREEN)✓$(NC) plugins/sdk-typescript/package.json version set to $(VERSION)" + + @# Update plugins/metadata-echo/package.json version + @cd plugins/metadata-echo && npm version $(VERSION) --no-git-tag-version --allow-same-version >/dev/null 2>&1 + @echo "$(GREEN)✓$(NC) plugins/metadata-echo/package.json version set to $(VERSION)" + + @# Update plugins/metadata-mangabaka/package.json version + @cd plugins/metadata-mangabaka && npm version $(VERSION) --no-git-tag-version --allow-same-version >/dev/null 2>&1 + @echo "$(GREEN)✓$(NC) plugins/metadata-mangabaka/package.json version set to $(VERSION)" + + @# Update docs/package.json version + @cd docs && npm version $(VERSION) --no-git-tag-version --allow-same-version >/dev/null 2>&1 + @echo "$(GREEN)✓$(NC) docs/package.json version set to $(VERSION)" + @# Update Cargo.lock @cargo build --quiet 2>/dev/null || cargo build @echo "$(GREEN)✓$(NC) Updated Cargo.lock" + @# Generate changelog (skip if already modified) @if git diff --quiet CHANGELOG.md 2>/dev/null && git diff --cached --quiet CHANGELOG.md 2>/dev/null; then \ $(MAKE) changelog-release VERSION=$(VERSION); \ diff --git a/docker-compose.yml b/docker-compose.yml index be195093..7ef1d7a2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -85,11 +85,17 @@ services: - ./docker/data/thumbnails:/app/data/thumbnails:rw - ./docker/data/uploads:/app/data/uploads:rw - ./docker/data/cache:/app/data/cache:rw + # Plugin dist directories (built by plugins-builder service) + - ./plugins/metadata-mangabaka/dist:/opt/codex/plugins/metadata-mangabaka/dist:ro + - ./plugins/metadata-echo/dist:/opt/codex/plugins/metadata-echo/dist:ro environment: RUST_BACKTRACE: 1 # Email configuration for Mailhog EMAIL_SMTP_HOST: mailhog EMAIL_SMTP_PORT: 1025 + # Encryption key for plugin secrets (base64-encoded 32-byte key) + # Generate with: openssl rand -base64 32 + CODEX_ENCRYPTION_KEY: "pjImnrzPzSmuvBKkzWAlTzrfyZ9O3pU/9IKuRT94Y/w=" # Disable workers in web container (workers run in separate container) CODEX_DISABLE_WORKERS: "true" # Configuration overrides (optional - uses CODEX_ prefix) @@ -134,6 +140,9 @@ services: - ./docker/data/thumbnails:/app/data/thumbnails:rw - ./docker/data/uploads:/app/data/uploads:rw - ./docker/data/cache:/app/data/cache:rw + # Plugin dist directories (built by plugins-builder service) + - ./plugins/metadata-mangabaka/dist:/opt/codex/plugins/metadata-mangabaka/dist:ro + - ./plugins/metadata-echo/dist:/opt/codex/plugins/metadata-echo/dist:ro command: [ "cargo", @@ -163,6 +172,8 @@ services: ] environment: RUST_BACKTRACE: 1 + # Encryption key for plugin secrets (must match codex-dev) + CODEX_ENCRYPTION_KEY: "pjImnrzPzSmuvBKkzWAlTzrfyZ9O3pU/9IKuRT94Y/w=" # Worker count (can also be set in config file) CODEX_TASK_WORKER_COUNT: "2" CODEX_SKIP_MIGRATIONS: "true" @@ -179,6 +190,36 @@ services: profiles: - dev + # Plugin builder - watches and rebuilds TypeScript plugins on changes + plugins-builder: + image: node:22-alpine + container_name: codex-plugins-builder + working_dir: /plugins + volumes: + - ./plugins:/plugins + # Exclude node_modules to prevent platform-specific binary conflicts + - /plugins/sdk-typescript/node_modules + - /plugins/metadata-echo/node_modules + - /plugins/metadata-mangabaka/node_modules + command: + - sh + - -c + - | + echo 'Installing plugin dependencies...' && + cd /plugins/sdk-typescript && npm install && npm run build && + cd /plugins/metadata-echo && npm install && npm run build && + cd /plugins/metadata-mangabaka && npm install && npm run build && + echo 'Initial build complete. Watching for changes...' && + npm install -g concurrently && + concurrently --names 'sdk,metadata-echo,metadata-mangabaka' --prefix-colors 'blue,green,yellow' \ + "cd /plugins/sdk-typescript && npm run dev" \ + "cd /plugins/metadata-echo && npm run dev" \ + "cd /plugins/metadata-mangabaka && npm run dev" + networks: + - codex-network + profiles: + - dev + # Frontend development server (Vite) # The Vite dev server proxies /api and /opds requests to codex-dev backend # Access the app at http://localhost:5173 @@ -298,10 +339,12 @@ services: - screenshots_thumbnails:/app/data/thumbnails:rw - screenshots_uploads:/app/data/uploads:rw - screenshots_cache:/app/data/cache:rw + - ./plugins/metadata-echo/dist:/opt/codex/plugins/metadata-echo/dist:ro command: ["codex", "serve", "--config", "/app/config/config.screenshots.yaml"] environment: CODEX_LOGGING_LEVEL: info + CODEX_ENCRYPTION_KEY: "pjImnrzPzSmuvBKkzWAlTzrfyZ9O3pU/9IKuRT94Y/w=" healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"] interval: 5s @@ -328,15 +371,6 @@ services: BASE_URL: http://codex-screenshots:8080 VIEWPORT_WIDTH: "1280" VIEWPORT_HEIGHT: "900" - # ADMIN_USERNAME: admin - # ADMIN_EMAIL: admin@example.com - # ADMIN_PASSWORD: "SecurePass123!" - # LIBRARY_1_NAME: Comics - # LIBRARY_1_PATH: /libraries/comics - # LIBRARY_2_NAME: Manga - # LIBRARY_2_PATH: /libraries/manga - # LIBRARY_3_NAME: Books - # LIBRARY_3_PATH: /libraries/books depends_on: codex-screenshots: condition: service_healthy diff --git a/docs/api/openapi.json b/docs/api/openapi.json index eaf04149..1abe1e29 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -7,7 +7,7 @@ "name": "MIT", "url": "https://opensource.org/licenses/MIT" }, - "version": "1.0.0" + "version": "1.0.1" }, "paths": { "/api/v1/admin/cleanup-orphans": { @@ -251,93 +251,84 @@ ] } }, - "/api/v1/admin/settings": { + "/api/v1/admin/plugins": { "get": { "tags": [ - "Settings" - ], - "summary": "List all settings (admin only)", - "operationId": "list_settings", - "parameters": [ - { - "name": "category", - "in": "query", - "description": "Filter by category", - "required": false, - "schema": { - "type": "string" - } - } + "Plugins" ], + "summary": "List all plugins", + "operationId": "list_plugins", "responses": { "200": { - "description": "List of settings", + "description": "Plugins retrieved", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SettingDto" - } + "$ref": "#/components/schemas/PluginsListResponse" } } } }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Forbidden - Admin only" + "description": "PluginsManage permission required" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - } - }, - "/api/v1/admin/settings/bulk": { + }, "post": { "tags": [ - "Settings" + "Plugins" ], - "summary": "Bulk update settings (admin only)", - "operationId": "bulk_update_settings", + "summary": "Create a new plugin", + "description": "Creates a new plugin configuration. If the plugin is created with `enabled: true`,\nan automatic health check is performed to verify connectivity.", + "operationId": "create_plugin", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BulkUpdateSettingsRequest" + "$ref": "#/components/schemas/CreatePluginRequest" } } }, "required": true }, "responses": { - "200": { - "description": "Settings updated", + "201": { + "description": "Plugin created", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SettingDto" - } + "$ref": "#/components/schemas/PluginStatusResponse" } } } }, "400": { - "description": "Invalid value" + "description": "Invalid request" + }, + "401": { + "description": "Unauthorized" }, "403": { - "description": "Forbidden - Admin only" + "description": "PluginsManage permission required" + }, + "409": { + "description": "Plugin with this name already exists" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -345,65 +336,111 @@ ] } }, - "/api/v1/admin/settings/{setting_key}": { + "/api/v1/admin/plugins/{id}": { "get": { "tags": [ - "Settings" + "Plugins" ], - "summary": "Get single setting by key (admin only)", - "operationId": "get_setting", + "summary": "Get a plugin by ID", + "operationId": "get_plugin", "parameters": [ { - "name": "setting_key", + "name": "id", "in": "path", - "description": "Setting key (e.g., scanner.max_concurrent_scans)", + "description": "Plugin ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], "responses": { "200": { - "description": "Setting details", + "description": "Plugin retrieved", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SettingDto" + "$ref": "#/components/schemas/PluginDto" } } } }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "PluginsManage permission required" + }, + "404": { + "description": "Plugin not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "delete": { + "tags": [ + "Plugins" + ], + "summary": "Delete a plugin", + "operationId": "delete_plugin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Plugin ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Plugin deleted" + }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Forbidden - Admin only" + "description": "PluginsManage permission required" }, "404": { - "description": "Setting not found" + "description": "Plugin not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] }, - "put": { + "patch": { "tags": [ - "Settings" + "Plugins" ], - "summary": "Update setting (admin only)", - "operationId": "update_setting", + "summary": "Update a plugin", + "operationId": "update_plugin", "parameters": [ { - "name": "setting_key", + "name": "id", "in": "path", - "description": "Setting key (e.g., scanner.max_concurrent_scans)", + "description": "Plugin ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -411,7 +448,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateSettingRequest" + "$ref": "#/components/schemas/UpdatePluginRequest" } } }, @@ -419,28 +456,31 @@ }, "responses": { "200": { - "description": "Setting updated", + "description": "Plugin updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SettingDto" + "$ref": "#/components/schemas/PluginDto" } } } }, "400": { - "description": "Invalid value" + "description": "Invalid request" + }, + "401": { + "description": "Unauthorized" }, "403": { - "description": "Forbidden - Admin only" + "description": "PluginsManage permission required" }, "404": { - "description": "Setting not found" + "description": "Plugin not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -448,58 +488,49 @@ ] } }, - "/api/v1/admin/settings/{setting_key}/history": { - "get": { + "/api/v1/admin/plugins/{id}/disable": { + "post": { "tags": [ - "Settings" + "Plugins" ], - "summary": "Get setting history (admin only)", - "operationId": "get_setting_history", + "summary": "Disable a plugin", + "operationId": "disable_plugin", "parameters": [ { - "name": "setting_key", + "name": "id", "in": "path", - "description": "Setting key (e.g., scanner.max_concurrent_scans)", + "description": "Plugin ID", "required": true, "schema": { - "type": "string" - } - }, - { - "name": "limit", - "in": "query", - "description": "Maximum number of history entries to return", - "required": false, - "schema": { - "type": "integer", - "format": "int64" + "type": "string", + "format": "uuid" } } ], "responses": { "200": { - "description": "Setting history", + "description": "Plugin disabled", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SettingHistoryDto" - } + "$ref": "#/components/schemas/PluginStatusResponse" } } } }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Forbidden - Admin only" + "description": "PluginsManage permission required" }, "404": { - "description": "Setting not found" + "description": "Plugin not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -507,45 +538,50 @@ ] } }, - "/api/v1/admin/settings/{setting_key}/reset": { + "/api/v1/admin/plugins/{id}/enable": { "post": { "tags": [ - "Settings" + "Plugins" ], - "summary": "Reset setting to default value (admin only)", - "operationId": "reset_setting", + "summary": "Enable a plugin", + "description": "Enables the plugin and automatically performs a health check to verify connectivity.", + "operationId": "enable_plugin", "parameters": [ { - "name": "setting_key", + "name": "id", "in": "path", - "description": "Setting key (e.g., scanner.max_concurrent_scans)", + "description": "Plugin ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], "responses": { "200": { - "description": "Setting reset to default", + "description": "Plugin enabled", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SettingDto" + "$ref": "#/components/schemas/PluginStatusResponse" } } } }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Forbidden - Admin only" + "description": "PluginsManage permission required" }, "404": { - "description": "Setting not found" + "description": "Plugin not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -553,18 +589,29 @@ ] } }, - "/api/v1/admin/sharing-tags": { + "/api/v1/admin/plugins/{id}/failures": { "get": { "tags": [ - "Sharing Tags" + "Plugins" ], - "summary": "List all sharing tags (admin only)", - "operationId": "list_sharing_tags", + "summary": "Get plugin failure history", + "description": "Returns failure events for a plugin, including time-window statistics.", + "operationId": "get_plugin_failures", "parameters": [ { - "name": "page", + "name": "id", + "in": "path", + "description": "Plugin ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", "in": "query", - "description": "Page number (1-indexed, default 1)", + "description": "Maximum number of failures to return", "required": false, "schema": { "type": "integer", @@ -573,9 +620,9 @@ } }, { - "name": "pageSize", + "name": "offset", "in": "query", - "description": "Number of items per page (default 50, max 500)", + "description": "Number of failures to skip", "required": false, "schema": { "type": "integer", @@ -586,65 +633,78 @@ ], "responses": { "200": { - "description": "List of sharing tags", + "description": "Failures retrieved", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse_SharingTagDto" + "$ref": "#/components/schemas/PluginFailuresResponse" } } } }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Forbidden - Missing permission" + "description": "PluginsManage permission required" + }, + "404": { + "description": "Plugin not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - }, - "post": { + } + }, + "/api/v1/admin/plugins/{id}/health": { + "get": { "tags": [ - "Sharing Tags" + "Plugins" ], - "summary": "Create a new sharing tag (admin only)", - "operationId": "create_sharing_tag", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateSharingTagRequest" - } + "summary": "Get plugin health information", + "operationId": "get_plugin_health", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Plugin ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" } - }, - "required": true - }, + } + ], "responses": { - "201": { - "description": "Sharing tag created", + "200": { + "description": "Health information retrieved", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SharingTagDto" + "$ref": "#/components/schemas/PluginHealthResponse" } } } }, - "400": { - "description": "Invalid request or tag name already exists" + "401": { + "description": "Unauthorized" }, "403": { - "description": "Forbidden - Missing permission" + "description": "PluginsManage permission required" + }, + "404": { + "description": "Plugin not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -652,18 +712,19 @@ ] } }, - "/api/v1/admin/sharing-tags/{tag_id}": { - "get": { + "/api/v1/admin/plugins/{id}/reset": { + "post": { "tags": [ - "Sharing Tags" + "Plugins" ], - "summary": "Get a sharing tag by ID (admin only)", - "operationId": "get_sharing_tag", + "summary": "Reset plugin failure count", + "description": "Clears the failure count and disabled reason, allowing the plugin to be used again.", + "operationId": "reset_plugin_failures", "parameters": [ { - "name": "tag_id", + "name": "id", "in": "path", - "description": "Sharing tag ID", + "description": "Plugin ID", "required": true, "schema": { "type": "string", @@ -673,80 +734,48 @@ ], "responses": { "200": { - "description": "Sharing tag details", + "description": "Failure count reset", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SharingTagDto" + "$ref": "#/components/schemas/PluginStatusResponse" } } } }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Forbidden - Missing permission" + "description": "PluginsManage permission required" }, "404": { - "description": "Sharing tag not found" + "description": "Plugin not found" } }, "security": [ { - "jwt_bearer": [] - }, - { - "api_key": [] - } - ] - }, - "delete": { - "tags": [ - "Sharing Tags" - ], - "summary": "Delete a sharing tag (admin only)", - "operationId": "delete_sharing_tag", - "parameters": [ - { - "name": "tag_id", - "in": "path", - "description": "Sharing tag ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "204": { - "description": "Sharing tag deleted" - }, - "403": { - "description": "Forbidden - Missing permission" - }, - "404": { - "description": "Sharing tag not found" - } - }, - "security": [ - { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - }, - "patch": { + } + }, + "/api/v1/admin/plugins/{id}/test": { + "post": { "tags": [ - "Sharing Tags" + "Plugins" ], - "summary": "Update a sharing tag (admin only)", - "operationId": "update_sharing_tag", + "summary": "Test a plugin connection", + "description": "Spawns the plugin process, sends an initialize request, and returns the manifest.", + "operationId": "test_plugin", "parameters": [ { - "name": "tag_id", + "name": "id", "in": "path", - "description": "Sharing tag ID", + "description": "Plugin ID", "required": true, "schema": { "type": "string", @@ -754,40 +783,30 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateSharingTagRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Sharing tag updated", + "description": "Test completed", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SharingTagDto" + "$ref": "#/components/schemas/PluginTestResult" } } } }, - "400": { - "description": "Invalid request or tag name already exists" + "401": { + "description": "Unauthorized" }, "403": { - "description": "Forbidden - Missing permission" + "description": "PluginsManage permission required" }, "404": { - "description": "Sharing tag not found" + "description": "Plugin not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -795,50 +814,40 @@ ] } }, - "/api/v1/api-keys": { + "/api/v1/admin/settings": { "get": { "tags": [ - "API Keys" + "Settings" ], - "summary": "List API keys for the authenticated user\nUsers can only see their own keys unless they are admin", - "operationId": "list_api_keys", + "summary": "List all settings (admin only)", + "operationId": "list_settings", "parameters": [ { - "name": "page", - "in": "query", - "description": "Page number (1-indexed, default 1)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, - { - "name": "pageSize", + "name": "category", "in": "query", - "description": "Number of items per page (default 50, max 500)", + "description": "Filter by category", "required": false, "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string" } } ], "responses": { "200": { - "description": "List of API keys", + "description": "List of settings", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse_ApiKeyDto" + "type": "array", + "items": { + "$ref": "#/components/schemas/SettingDto" + } } } } }, "403": { - "description": "Forbidden - Missing permission" + "description": "Forbidden - Admin only" } }, "security": [ @@ -849,39 +858,44 @@ "api_key": [] } ] - }, + } + }, + "/api/v1/admin/settings/bulk": { "post": { "tags": [ - "API Keys" + "Settings" ], - "summary": "Create a new API key\nAPI keys are always associated with the authenticated user", - "operationId": "create_api_key", + "summary": "Bulk update settings (admin only)", + "operationId": "bulk_update_settings", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateApiKeyRequest" + "$ref": "#/components/schemas/BulkUpdateSettingsRequest" } } }, "required": true }, "responses": { - "201": { - "description": "API key created", + "200": { + "description": "Settings updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateApiKeyResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/SettingDto" + } } } } }, "400": { - "description": "Invalid request" + "description": "Invalid value" }, "403": { - "description": "Forbidden - Missing permission" + "description": "Forbidden - Admin only" } }, "security": [ @@ -894,41 +908,40 @@ ] } }, - "/api/v1/api-keys/{api_key_id}": { + "/api/v1/admin/settings/{setting_key}": { "get": { "tags": [ - "API Keys" + "Settings" ], - "summary": "Get API key by ID\nUsers can only get their own keys unless they are admin", - "operationId": "get_api_key", + "summary": "Get single setting by key (admin only)", + "operationId": "get_setting", "parameters": [ { - "name": "api_key_id", + "name": "setting_key", "in": "path", - "description": "API key ID", + "description": "Setting key (e.g., scanner.max_concurrent_scans)", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], "responses": { "200": { - "description": "API key details", + "description": "Setting details", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiKeyDto" + "$ref": "#/components/schemas/SettingDto" } } } }, "403": { - "description": "Forbidden - Missing permission or not owner" + "description": "Forbidden - Admin only" }, "404": { - "description": "API key not found" + "description": "Setting not found" } }, "security": [ @@ -940,33 +953,52 @@ } ] }, - "delete": { + "put": { "tags": [ - "API Keys" + "Settings" ], - "summary": "Delete an API key\nUsers can only delete their own keys unless they are admin", - "operationId": "delete_api_key", + "summary": "Update setting (admin only)", + "operationId": "update_setting", "parameters": [ { - "name": "api_key_id", + "name": "setting_key", "in": "path", - "description": "API key ID", + "description": "Setting key (e.g., scanner.max_concurrent_scans)", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSettingRequest" + } + } + }, + "required": true + }, "responses": { - "204": { - "description": "API key deleted" + "200": { + "description": "Setting updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SettingDto" + } + } + } + }, + "400": { + "description": "Invalid value" }, "403": { - "description": "Forbidden - Missing permission or not owner" + "description": "Forbidden - Admin only" }, "404": { - "description": "API key not found" + "description": "Setting not found" } }, "security": [ @@ -977,51 +1009,55 @@ "api_key": [] } ] - }, - "patch": { + } + }, + "/api/v1/admin/settings/{setting_key}/history": { + "get": { "tags": [ - "API Keys" + "Settings" ], - "summary": "Update an API key (partial update)\nUsers can only update their own keys unless they are admin", - "operationId": "update_api_key", + "summary": "Get setting history (admin only)", + "operationId": "get_setting_history", "parameters": [ { - "name": "api_key_id", + "name": "setting_key", "in": "path", - "description": "API key ID", + "description": "Setting key (e.g., scanner.max_concurrent_scans)", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of history entries to return", + "required": false, + "schema": { + "type": "integer", + "format": "int64" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateApiKeyRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "API key updated", + "description": "Setting history", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiKeyDto" + "type": "array", + "items": { + "$ref": "#/components/schemas/SettingHistoryDto" + } } } } }, "403": { - "description": "Forbidden - Missing permission or not owner" + "description": "Forbidden - Admin only" }, "404": { - "description": "API key not found" + "description": "Setting not found" } }, "security": [ @@ -1034,52 +1070,40 @@ ] } }, - "/api/v1/auth/login": { + "/api/v1/admin/settings/{setting_key}/reset": { "post": { "tags": [ - "Auth" + "Settings" ], - "summary": "Login handler", - "description": "Authenticates a user with username/email and password, returns JWT token", - "operationId": "login", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginRequest" - } + "summary": "Reset setting to default value (admin only)", + "operationId": "reset_setting", + "parameters": [ + { + "name": "setting_key", + "in": "path", + "description": "Setting key (e.g., scanner.max_concurrent_scans)", + "required": true, + "schema": { + "type": "string" } - }, - "required": true - }, + } + ], "responses": { "200": { - "description": "Login successful", + "description": "Setting reset to default", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LoginResponse" + "$ref": "#/components/schemas/SettingDto" } } } }, - "401": { - "description": "Invalid credentials" - } - } - } - }, - "/api/v1/auth/logout": { - "post": { - "tags": [ - "Auth" - ], - "summary": "Logout handler", - "description": "No-op for stateless JWT - client should discard token", - "operationId": "logout", - "responses": { - "200": { - "description": "Logout successful" + "403": { + "description": "Forbidden - Admin only" + }, + "404": { + "description": "Setting not found" } }, "security": [ @@ -1092,205 +1116,140 @@ ] } }, - "/api/v1/auth/register": { - "post": { + "/api/v1/admin/sharing-tags": { + "get": { "tags": [ - "Auth" + "Sharing Tags" ], - "summary": "Register handler", - "description": "Creates a new user account with username, email, and password", - "operationId": "register", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegisterRequest" - } + "summary": "List all sharing tags (admin only)", + "operationId": "list_sharing_tags", + "parameters": [ + { + "name": "page", + "in": "query", + "description": "Page number (1-indexed, default 1)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 } }, - "required": true - }, - "responses": { - "201": { - "description": "User registered successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegisterResponse" - } - } + { + "name": "pageSize", + "in": "query", + "description": "Number of items per page (default 50, max 500)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 } - }, - "400": { - "description": "Invalid request or user already exists" - }, - "422": { - "description": "Validation error" } - } - } - }, - "/api/v1/auth/resend-verification": { - "post": { - "tags": [ - "Auth" ], - "summary": "Resend verification email handler", - "description": "Resends the verification email to a user", - "operationId": "resend_verification", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ResendVerificationRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Verification email sent", + "description": "List of sharing tags", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResendVerificationResponse" + "$ref": "#/components/schemas/PaginatedResponse_SharingTagDto" } } } }, - "400": { - "description": "Invalid request or email already verified" + "403": { + "description": "Forbidden - Missing permission" } - } - } - }, - "/api/v1/auth/verify-email": { + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + }, "post": { "tags": [ - "Auth" + "Sharing Tags" ], - "summary": "Verify email handler", - "description": "Verifies a user's email address using the token sent via email", - "operationId": "verify_email", + "summary": "Create a new sharing tag (admin only)", + "operationId": "create_sharing_tag", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VerifyEmailRequest" + "$ref": "#/components/schemas/CreateSharingTagRequest" } } }, "required": true }, "responses": { - "200": { - "description": "Email verified successfully", + "201": { + "description": "Sharing tag created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VerifyEmailResponse" + "$ref": "#/components/schemas/SharingTagDto" } } } }, "400": { - "description": "Invalid or expired token" + "description": "Invalid request or tag name already exists" + }, + "403": { + "description": "Forbidden - Missing permission" } - } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] } }, - "/api/v1/books": { + "/api/v1/admin/sharing-tags/{tag_id}": { "get": { "tags": [ - "Books" + "Sharing Tags" ], - "summary": "List books with pagination", - "operationId": "list_books", + "summary": "Get a sharing tag by ID (admin only)", + "operationId": "get_sharing_tag", "parameters": [ { - "name": "libraryId", - "in": "query", - "description": "Optional library filter", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "seriesId", - "in": "query", - "description": "Optional series filter", - "required": false, + "name": "tag_id", + "in": "path", + "description": "Sharing tag ID", + "required": true, "schema": { - "type": [ - "string", - "null" - ], + "type": "string", "format": "uuid" } - }, - { - "name": "page", - "in": "query", - "description": "Page number (1-indexed, minimum 1)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, - { - "name": "pageSize", - "in": "query", - "description": "Number of items per page (max 100, default 50)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, - { - "name": "sort", - "in": "query", - "description": "Sort parameter (format: \"field,direction\" e.g. \"title,asc\")", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "full", - "in": "query", - "description": "Return full data including metadata and locks.\nDefault is false for backward compatibility.", - "required": false, - "schema": { - "type": "boolean" - } } ], "responses": { "200": { - "description": "Paginated list of books (returns FullBookListResponse when full=true)", + "description": "Sharing tag details", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse" + "$ref": "#/components/schemas/SharingTagDto" } } } }, "403": { - "description": "Forbidden" + "description": "Forbidden - Missing permission" + }, + "404": { + "description": "Sharing tag not found" } }, "security": [ @@ -1301,113 +1260,92 @@ "api_key": [] } ] - } - }, - "/api/v1/books/errors": { - "get": { + }, + "delete": { "tags": [ - "Books" + "Sharing Tags" ], - "summary": "List books with errors (v2 - grouped by error type)", - "description": "Returns books with errors grouped by error type, with counts and pagination.\nThis enhanced endpoint provides detailed error information including error\ntypes, messages, and timestamps.", - "operationId": "list_books_with_errors_v2", + "summary": "Delete a sharing tag (admin only)", + "operationId": "delete_sharing_tag", "parameters": [ { - "name": "libraryId", - "in": "query", - "description": "Optional library filter", - "required": false, + "name": "tag_id", + "in": "path", + "description": "Sharing tag ID", + "required": true, "schema": { - "type": [ - "string", - "null" - ], + "type": "string", "format": "uuid" } + } + ], + "responses": { + "204": { + "description": "Sharing tag deleted" }, - { - "name": "seriesId", - "in": "query", - "description": "Optional series filter", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } + "403": { + "description": "Forbidden - Missing permission" }, + "404": { + "description": "Sharing tag not found" + } + }, + "security": [ { - "name": "errorType", - "in": "query", - "description": "Filter by specific error type", - "required": false, - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/BookErrorTypeDto" - } - ] - } + "jwt_bearer": [] }, { - "name": "page", - "in": "query", - "description": "Page number (1-indexed, default 1)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, + "api_key": [] + } + ] + }, + "patch": { + "tags": [ + "Sharing Tags" + ], + "summary": "Update a sharing tag (admin only)", + "operationId": "update_sharing_tag", + "parameters": [ { - "name": "pageSize", - "in": "query", - "description": "Number of items per page (max 100, default 50)", - "required": false, + "name": "tag_id", + "in": "path", + "description": "Sharing tag ID", + "required": true, "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string", + "format": "uuid" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSharingTagRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Books with errors grouped by type", + "description": "Sharing tag updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BooksWithErrorsResponse" - }, - "example": { - "errorCounts": { - "parser": 5, - "thumbnail": 10 - }, - "groups": [ - { - "books": [], - "count": 5, - "errorType": "parser", - "label": "Parser Error" - } - ], - "page": 0, - "pageSize": 20, - "totalBooksWithErrors": 15, - "totalPages": 1 + "$ref": "#/components/schemas/SharingTagDto" } } } }, + "400": { + "description": "Invalid request or tag name already exists" + }, "403": { - "description": "Forbidden" + "description": "Forbidden - Missing permission" + }, + "404": { + "description": "Sharing tag not found" } }, "security": [ @@ -1420,44 +1358,18 @@ ] } }, - "/api/v1/books/in-progress": { + "/api/v1/api-keys": { "get": { "tags": [ - "Books" + "API Keys" ], - "summary": "List books with reading progress (in-progress books)", - "operationId": "list_in_progress_books", + "summary": "List API keys for the authenticated user\nUsers can only see their own keys unless they are admin", + "operationId": "list_api_keys", "parameters": [ - { - "name": "libraryId", - "in": "query", - "description": "Optional library filter", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "seriesId", - "in": "query", - "description": "Optional series filter", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, { "name": "page", "in": "query", - "description": "Page number (1-indexed, minimum 1)", + "description": "Page number (1-indexed, default 1)", "required": false, "schema": { "type": "integer", @@ -1468,49 +1380,71 @@ { "name": "pageSize", "in": "query", - "description": "Number of items per page (max 100, default 50)", + "description": "Number of items per page (default 50, max 500)", "required": false, "schema": { "type": "integer", "format": "int64", "minimum": 0 } + } + ], + "responses": { + "200": { + "description": "List of API keys", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse_ApiKeyDto" + } + } + } }, + "403": { + "description": "Forbidden - Missing permission" + } + }, + "security": [ { - "name": "sort", - "in": "query", - "description": "Sort parameter (format: \"field,direction\" e.g. \"title,asc\")", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } + "jwt_bearer": [] }, { - "name": "full", - "in": "query", - "description": "Return full data including metadata and locks.\nDefault is false for backward compatibility.", - "required": false, - "schema": { - "type": "boolean" - } + "api_key": [] } + ] + }, + "post": { + "tags": [ + "API Keys" ], + "summary": "Create a new API key\nAPI keys are always associated with the authenticated user", + "operationId": "create_api_key", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateApiKeyRequest" + } + } + }, + "required": true + }, "responses": { - "200": { - "description": "Paginated list of in-progress books", + "201": { + "description": "API key created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse" + "$ref": "#/components/schemas/CreateApiKeyResponse" } } } }, + "400": { + "description": "Invalid request" + }, "403": { - "description": "Forbidden" + "description": "Forbidden - Missing permission" } }, "security": [ @@ -1523,59 +1457,105 @@ ] } }, - "/api/v1/books/list": { - "post": { + "/api/v1/api-keys/{api_key_id}": { + "get": { "tags": [ - "Books" + "API Keys" ], - "summary": "List books with advanced filtering", - "description": "Supports complex filter conditions including nested AllOf/AnyOf logic,\ngenre/tag filtering with include/exclude, and more.\n\nPagination parameters (page, pageSize, sort, full) are passed as query parameters.\nFilter conditions are passed in the request body.", - "operationId": "list_books_filtered", + "summary": "Get API key by ID\nUsers can only get their own keys unless they are admin", + "operationId": "get_api_key", "parameters": [ { - "name": "page", - "in": "query", - "description": "Page number (1-indexed, minimum 1)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "default": 1, - "minimum": 1 + "name": "api_key_id", + "in": "path", + "description": "API key ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "API key details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyDto" + } + } } }, + "403": { + "description": "Forbidden - Missing permission or not owner" + }, + "404": { + "description": "API key not found" + } + }, + "security": [ { - "name": "pageSize", - "in": "query", - "description": "Number of items per page (max 500, default 50)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "default": 50, - "maximum": 500, - "minimum": 1 - } + "jwt_bearer": [] }, { - "name": "sort", - "in": "query", - "description": "Sort field and direction (e.g., \"name,asc\" or \"createdAt,desc\")", - "required": false, + "api_key": [] + } + ] + }, + "delete": { + "tags": [ + "API Keys" + ], + "summary": "Delete an API key\nUsers can only delete their own keys unless they are admin", + "operationId": "delete_api_key", + "parameters": [ + { + "name": "api_key_id", + "in": "path", + "description": "API key ID", + "required": true, "schema": { - "type": [ - "string", - "null" - ] + "type": "string", + "format": "uuid" } + } + ], + "responses": { + "204": { + "description": "API key deleted" + }, + "403": { + "description": "Forbidden - Missing permission or not owner" + }, + "404": { + "description": "API key not found" + } + }, + "security": [ + { + "jwt_bearer": [] }, { - "name": "full", - "in": "query", - "description": "Return full data including metadata, locks, and related entities.\nDefault is false for backward compatibility.", - "required": false, + "api_key": [] + } + ] + }, + "patch": { + "tags": [ + "API Keys" + ], + "summary": "Update an API key (partial update)\nUsers can only update their own keys unless they are admin", + "operationId": "update_api_key", + "parameters": [ + { + "name": "api_key_id", + "in": "path", + "description": "API key ID", + "required": true, "schema": { - "type": "boolean" + "type": "string", + "format": "uuid" } } ], @@ -1583,7 +1563,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookListRequest" + "$ref": "#/components/schemas/UpdateApiKeyRequest" } } }, @@ -1591,17 +1571,20 @@ }, "responses": { "200": { - "description": "Paginated list of filtered books (returns FullBookListResponse when full=true)", + "description": "API key updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse" + "$ref": "#/components/schemas/ApiKeyDto" } } } }, "403": { - "description": "Forbidden" + "description": "Forbidden - Missing permission or not owner" + }, + "404": { + "description": "API key not found" } }, "security": [ @@ -1614,97 +1597,52 @@ ] } }, - "/api/v1/books/on-deck": { - "get": { + "/api/v1/auth/login": { + "post": { "tags": [ - "Books" + "Auth" ], - "summary": "List on-deck books (next unread book in series where user has completed at least one book)", - "operationId": "list_on_deck_books", - "parameters": [ - { - "name": "libraryId", - "in": "query", - "description": "Optional library filter", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "seriesId", - "in": "query", - "description": "Optional series filter", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "page", - "in": "query", - "description": "Page number (1-indexed, minimum 1)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, - { - "name": "pageSize", - "in": "query", - "description": "Number of items per page (max 100, default 50)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, - { - "name": "sort", - "in": "query", - "description": "Sort parameter (format: \"field,direction\" e.g. \"title,asc\")", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] + "summary": "Login handler", + "description": "Authenticates a user with username/email and password, returns JWT token", + "operationId": "login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } } }, - { - "name": "full", - "in": "query", - "description": "Return full data including metadata and locks.\nDefault is false for backward compatibility.", - "required": false, - "schema": { - "type": "boolean" - } - } - ], + "required": true + }, "responses": { "200": { - "description": "Paginated list of on-deck books", + "description": "Login successful", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse" + "$ref": "#/components/schemas/LoginResponse" } } } }, - "403": { - "description": "Forbidden" + "401": { + "description": "Invalid credentials" + } + } + } + }, + "/api/v1/auth/logout": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Logout handler", + "description": "No-op for stateless JWT - client should discard token", + "operationId": "logout", + "responses": { + "200": { + "description": "Logout successful" } }, "security": [ @@ -1717,33 +1655,141 @@ ] } }, - "/api/v1/books/recently-added": { - "get": { + "/api/v1/auth/register": { + "post": { "tags": [ - "Books" + "Auth" ], - "summary": "List recently added books", - "operationId": "list_recently_added_books", - "parameters": [ - { - "name": "libraryId", - "in": "query", - "description": "Optional library filter", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "seriesId", - "in": "query", - "description": "Optional series filter", - "required": false, - "schema": { + "summary": "Register handler", + "description": "Creates a new user account with username, email, and password", + "operationId": "register", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "User registered successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterResponse" + } + } + } + }, + "400": { + "description": "Invalid request or user already exists" + }, + "422": { + "description": "Validation error" + } + } + } + }, + "/api/v1/auth/resend-verification": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Resend verification email handler", + "description": "Resends the verification email to a user", + "operationId": "resend_verification", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResendVerificationRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Verification email sent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResendVerificationResponse" + } + } + } + }, + "400": { + "description": "Invalid request or email already verified" + } + } + } + }, + "/api/v1/auth/verify-email": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Verify email handler", + "description": "Verifies a user's email address using the token sent via email", + "operationId": "verify_email", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyEmailRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Email verified successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyEmailResponse" + } + } + } + }, + "400": { + "description": "Invalid or expired token" + } + } + } + }, + "/api/v1/books": { + "get": { + "tags": [ + "Books" + ], + "summary": "List books with pagination", + "operationId": "list_books", + "parameters": [ + { + "name": "libraryId", + "in": "query", + "description": "Optional library filter", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + }, + { + "name": "seriesId", + "in": "query", + "description": "Optional series filter", + "required": false, + "schema": { "type": [ "string", "null" @@ -1797,7 +1843,7 @@ ], "responses": { "200": { - "description": "Paginated list of recently added books", + "description": "Paginated list of books (returns FullBookListResponse when full=true)", "content": { "application/json": { "schema": { @@ -1820,60 +1866,91 @@ ] } }, - "/api/v1/books/recently-read": { - "get": { + "/api/v1/books/bulk/analyze": { + "post": { "tags": [ - "Books" + "Bulk Operations" ], - "summary": "List recently read books (ordered by last read activity)", - "operationId": "list_recently_read_books", - "parameters": [ - { - "name": "limit", - "in": "query", - "description": "Maximum number of books to return (default: 50)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "summary": "Bulk analyze multiple books", + "description": "Enqueues analysis tasks for all specified books.\nBooks that don't exist are silently skipped.", + "operationId": "bulk_analyze_books", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkAnalyzeBooksRequest" + } } }, - { - "name": "libraryId", - "in": "query", - "description": "Filter by library ID (optional)", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" + "required": true + }, + "responses": { + "200": { + "description": "Analysis tasks enqueued", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkAnalyzeResponse" + } + } } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] } + ] + } + }, + "/api/v1/books/bulk/read": { + "post": { + "tags": [ + "Bulk Operations" ], + "summary": "Bulk mark multiple books as read", + "description": "Marks all specified books as read for the authenticated user.\nBooks that don't exist are silently skipped.", + "operationId": "bulk_mark_books_as_read", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkBooksRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "List of recently read books", + "description": "Books marked as read", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BookDto" - } + "$ref": "#/components/schemas/MarkReadResponse" } } } }, + "401": { + "description": "Unauthorized" + }, "403": { "description": "Forbidden" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -1881,23 +1958,19 @@ ] } }, - "/api/v1/books/retry-all-errors": { + "/api/v1/books/bulk/unread": { "post": { "tags": [ - "Books" + "Bulk Operations" ], - "summary": "Retry all failed operations across all books", - "description": "Enqueues appropriate tasks for all books with errors.\nCan be filtered by error type or library.", - "operationId": "retry_all_book_errors", + "summary": "Bulk mark multiple books as unread", + "description": "Marks all specified books as unread for the authenticated user.\nBooks that don't exist or have no progress are silently skipped.", + "operationId": "bulk_mark_books_as_unread", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RetryAllErrorsRequest" - }, - "example": { - "errorType": "parser", - "libraryId": null + "$ref": "#/components/schemas/BulkBooksRequest" } } }, @@ -1905,26 +1978,25 @@ }, "responses": { "200": { - "description": "Retry tasks enqueued", + "description": "Books marked as unread", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RetryErrorsResponse" - }, - "example": { - "message": "Enqueued 10 analysis tasks and 5 thumbnail tasks", - "tasksEnqueued": 15 + "$ref": "#/components/schemas/MarkReadResponse" } } } }, + "401": { + "description": "Unauthorized" + }, "403": { "description": "Forbidden" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -1932,12 +2004,13 @@ ] } }, - "/api/v1/books/with-errors": { + "/api/v1/books/errors": { "get": { "tags": [ "Books" ], - "summary": "List books with analysis errors", + "summary": "List books with errors (grouped by error type)", + "description": "Returns books with errors grouped by error type, with counts and pagination.\nThis endpoint provides detailed error information including error\ntypes, messages, and timestamps.", "operationId": "list_books_with_errors", "parameters": [ { @@ -1966,10 +2039,26 @@ "format": "uuid" } }, + { + "name": "errorType", + "in": "query", + "description": "Filter by specific error type", + "required": false, + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/BookErrorTypeDto" + } + ] + } + }, { "name": "page", "in": "query", - "description": "Page number (1-indexed, minimum 1)", + "description": "Page number (1-indexed, default 1)", "required": false, "schema": { "type": "integer", @@ -1991,12 +2080,30 @@ ], "responses": { "200": { - "description": "Paginated list of books with analysis errors", + "description": "Books with errors grouped by type", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse" - } + "$ref": "#/components/schemas/BooksWithErrorsResponse" + }, + "example": { + "errorCounts": { + "parser": 5, + "thumbnail": 10 + }, + "groups": [ + { + "books": [], + "count": 5, + "errorType": "parser", + "label": "Parser Error" + } + ], + "page": 0, + "pageSize": 20, + "totalBooksWithErrors": 15, + "totalPages": 1 + } } } }, @@ -2014,24 +2121,74 @@ ] } }, - "/api/v1/books/{book_id}": { + "/api/v1/books/in-progress": { "get": { "tags": [ "Books" ], - "summary": "Get book by ID", - "operationId": "get_book", + "summary": "List books with reading progress (in-progress books)", + "operationId": "list_in_progress_books", "parameters": [ { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, + "name": "libraryId", + "in": "query", + "description": "Optional library filter", + "required": false, "schema": { - "type": "string", + "type": [ + "string", + "null" + ], + "format": "uuid" + } + }, + { + "name": "seriesId", + "in": "query", + "description": "Optional series filter", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], "format": "uuid" } }, + { + "name": "page", + "in": "query", + "description": "Page number (1-indexed, minimum 1)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "pageSize", + "in": "query", + "description": "Number of items per page (max 100, default 50)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "sort", + "in": "query", + "description": "Sort parameter (format: \"field,direction\" e.g. \"title,asc\")", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, { "name": "full", "in": "query", @@ -2044,17 +2201,17 @@ ], "responses": { "200": { - "description": "Book details (returns FullBookResponse when full=true)", + "description": "Paginated list of in-progress books", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookDetailResponse" + "$ref": "#/components/schemas/PaginatedResponse" } } } }, - "404": { - "description": "Book not found" + "403": { + "description": "Forbidden" } }, "security": [ @@ -2067,39 +2224,85 @@ ] } }, - "/api/v1/books/{book_id}/adjacent": { - "get": { + "/api/v1/books/list": { + "post": { "tags": [ "Books" ], - "summary": "Get adjacent books in the same series", - "description": "Returns the previous and next books relative to the requested book,\nordered by book number within the series.", - "operationId": "get_adjacent_books", + "summary": "List books with advanced filtering", + "description": "Supports complex filter conditions including nested AllOf/AnyOf logic,\ngenre/tag filtering with include/exclude, and more.\n\nPagination parameters (page, pageSize, sort, full) are passed as query parameters.\nFilter conditions are passed in the request body.", + "operationId": "list_books_filtered", "parameters": [ { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, + "name": "page", + "in": "query", + "description": "Page number (1-indexed, minimum 1)", + "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "format": "int64", + "default": 1, + "minimum": 1 + } + }, + { + "name": "pageSize", + "in": "query", + "description": "Number of items per page (max 500, default 50)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "default": 50, + "maximum": 500, + "minimum": 1 + } + }, + { + "name": "sort", + "in": "query", + "description": "Sort field and direction (e.g., \"name,asc\" or \"createdAt,desc\")", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "full", + "in": "query", + "description": "Return full data including metadata, locks, and related entities.\nDefault is false for backward compatibility.", + "required": false, + "schema": { + "type": "boolean" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookListRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Adjacent books", + "description": "Paginated list of filtered books (returns FullBookListResponse when full=true)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdjacentBooksResponse" + "$ref": "#/components/schemas/PaginatedResponse" } } } }, - "404": { - "description": "Book not found" + "403": { + "description": "Forbidden" } }, "security": [ @@ -2112,47 +2315,102 @@ ] } }, - "/api/v1/books/{book_id}/analyze": { - "post": { + "/api/v1/books/on-deck": { + "get": { "tags": [ - "Scans" + "Books" ], - "summary": "Trigger analysis of a single book (force reanalysis)", - "description": "# Permission Required\n- `books:write`\n\n# Behavior\nEnqueues an AnalyzeBook task with force=true.\nReturns immediately with a task_id to track progress.", - "operationId": "trigger_book_analysis", + "summary": "List on-deck books (next unread book in series where user has completed at least one book)", + "operationId": "list_on_deck_books", "parameters": [ { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, + "name": "libraryId", + "in": "query", + "description": "Optional library filter", + "required": false, "schema": { - "type": "string", + "type": [ + "string", + "null" + ], + "format": "uuid" + } + }, + { + "name": "seriesId", + "in": "query", + "description": "Optional series filter", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], "format": "uuid" } + }, + { + "name": "page", + "in": "query", + "description": "Page number (1-indexed, minimum 1)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "pageSize", + "in": "query", + "description": "Number of items per page (max 100, default 50)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "sort", + "in": "query", + "description": "Sort parameter (format: \"field,direction\" e.g. \"title,asc\")", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "full", + "in": "query", + "description": "Return full data including metadata and locks.\nDefault is false for backward compatibility.", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { "200": { - "description": "Analysis task enqueued successfully", + "description": "Paginated list of on-deck books", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTaskResponse" + "$ref": "#/components/schemas/PaginatedResponse" } } } }, "403": { - "description": "Permission denied" - }, - "404": { - "description": "Book not found" + "description": "Forbidden" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -2160,50 +2418,102 @@ ] } }, - "/api/v1/books/{book_id}/analyze-unanalyzed": { - "post": { + "/api/v1/books/recently-added": { + "get": { "tags": [ - "Scans" + "Books" ], - "summary": "Trigger analysis of a book if not already analyzed", - "description": "# Permission Required\n- `books:write`\n\n# Behavior\nEnqueues an AnalyzeBook task with force=false if the book has not been analyzed yet.\nReturns 400 if the book is already analyzed.\nReturns immediately with a task_id to track progress.", - "operationId": "trigger_book_unanalyzed_analysis", + "summary": "List recently added books", + "operationId": "list_recently_added_books", "parameters": [ { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, + "name": "libraryId", + "in": "query", + "description": "Optional library filter", + "required": false, "schema": { - "type": "string", + "type": [ + "string", + "null" + ], + "format": "uuid" + } + }, + { + "name": "seriesId", + "in": "query", + "description": "Optional series filter", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], "format": "uuid" } + }, + { + "name": "page", + "in": "query", + "description": "Page number (1-indexed, minimum 1)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "pageSize", + "in": "query", + "description": "Number of items per page (max 100, default 50)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "sort", + "in": "query", + "description": "Sort parameter (format: \"field,direction\" e.g. \"title,asc\")", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "full", + "in": "query", + "description": "Return full data including metadata and locks.\nDefault is false for backward compatibility.", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { "200": { - "description": "Analysis task enqueued successfully", + "description": "Paginated list of recently added books", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTaskResponse" + "$ref": "#/components/schemas/PaginatedResponse" } } } }, - "400": { - "description": "Book is already analyzed" - }, "403": { - "description": "Permission denied" - }, - "404": { - "description": "Book not found" + "description": "Forbidden" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -2211,38 +2521,55 @@ ] } }, - "/api/v1/books/{book_id}/file": { + "/api/v1/books/recently-read": { "get": { "tags": [ "Books" ], - "summary": "Download book file", - "description": "Streams the original book file (CBZ, CBR, EPUB, PDF) for download.\nUsed by OPDS clients for acquisition links.", - "operationId": "get_book_file", + "summary": "List recently read books (ordered by last read activity)", + "operationId": "list_recently_read_books", "parameters": [ { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, + "name": "limit", + "in": "query", + "description": "Maximum number of books to return (default: 50)", + "required": false, "schema": { - "type": "string", + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "libraryId", + "in": "query", + "description": "Filter by library ID (optional)", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], "format": "uuid" } } ], "responses": { "200": { - "description": "Book file", + "description": "List of recently read books", "content": { - "application/octet-stream": {} + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookDto" + } + } + } } }, "403": { "description": "Forbidden" - }, - "404": { - "description": "Book not found" } }, "security": [ @@ -2255,31 +2582,23 @@ ] } }, - "/api/v1/books/{book_id}/metadata": { - "put": { + "/api/v1/books/retry-all-errors": { + "post": { "tags": [ "Books" ], - "summary": "Replace all book metadata (PUT)", - "description": "Completely replaces all metadata fields. Omitted or null fields will be cleared.\nIf no metadata record exists, one will be created.", - "operationId": "replace_book_metadata", - "parameters": [ - { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], + "summary": "Retry all failed operations across all books", + "description": "Enqueues appropriate tasks for all books with errors.\nCan be filtered by error type or library.", + "operationId": "retry_all_book_errors", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReplaceBookMetadataRequest" + "$ref": "#/components/schemas/RetryAllErrorsRequest" + }, + "example": { + "errorType": "parser", + "libraryId": null } } }, @@ -2287,20 +2606,21 @@ }, "responses": { "200": { - "description": "Metadata replaced successfully", + "description": "Retry tasks enqueued", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookMetadataResponse" + "$ref": "#/components/schemas/RetryErrorsResponse" + }, + "example": { + "message": "Enqueued 10 analysis tasks and 5 thumbnail tasks", + "tasksEnqueued": 15 } } } }, "403": { "description": "Forbidden" - }, - "404": { - "description": "Book not found" } }, "security": [ @@ -2311,31 +2631,21 @@ "api_key": [] } ] - }, - "patch": { + } + }, + "/api/v1/books/thumbnails/generate": { + "post": { "tags": [ - "Books" - ], - "summary": "Partially update book metadata (PATCH)", - "description": "Only provided fields will be updated. Absent fields are unchanged.\nExplicitly null fields will be cleared.\nIf no metadata record exists, one will be created with the provided fields.", - "operationId": "patch_book_metadata", - "parameters": [ - { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Thumbnails" ], + "summary": "Generate thumbnails for books in a scope", + "description": "This queues a fan-out task that enqueues individual thumbnail generation tasks for each book.\n\n**Scope priority:**\n1. If `series_id` is provided, only books in that series\n2. If `library_id` is provided, only books in that library\n3. If neither is provided, all books in all libraries\n\n**Force behavior:**\n- `force: false` (default): Only generates thumbnails for books that don't have one\n- `force: true`: Regenerates all thumbnails, replacing existing ones\n\n# Permission Required\n- `tasks:write`", + "operationId": "generate_book_thumbnails", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchBookMetadataRequest" + "$ref": "#/components/schemas/GenerateBookThumbnailsRequest" } } }, @@ -2343,25 +2653,22 @@ }, "responses": { "200": { - "description": "Metadata updated successfully", + "description": "Thumbnail generation task queued", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookMetadataResponse" + "$ref": "#/components/schemas/CreateTaskResponse" } } } }, "403": { - "description": "Forbidden" - }, - "404": { - "description": "Book not found" + "description": "Permission denied" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -2369,14 +2676,13 @@ ] } }, - "/api/v1/books/{book_id}/pages/{page_number}": { + "/api/v1/books/{book_id}": { "get": { "tags": [ - "Pages" + "Books" ], - "summary": "Get page image from a book", - "description": "Extracts and serves the image for a specific page from CBZ/CBR/EPUB/PDF.\nFor PDF pages, supports HTTP conditional caching with ETag and Last-Modified\nheaders, returning 304 Not Modified when the client has a valid cached copy.", - "operationId": "get_page_image", + "summary": "Get book by ID", + "operationId": "get_book", "parameters": [ { "name": "book_id", @@ -2389,31 +2695,28 @@ } }, { - "name": "page_number", - "in": "path", - "description": "Page number (1-indexed)", - "required": true, + "name": "full", + "in": "query", + "description": "Return full data including metadata and locks.\nDefault is false for backward compatibility.", + "required": false, "schema": { - "type": "integer", - "format": "int32" + "type": "boolean" } } ], "responses": { "200": { - "description": "Page image", + "description": "Book details (returns FullBookResponse when full=true)", "content": { - "image/jpeg": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookDetailResponse" + } + } } }, - "304": { - "description": "Not modified (client cache is valid)" - }, - "403": { - "description": "Forbidden" - }, "404": { - "description": "Book or page not found" + "description": "Book not found" } }, "security": [ @@ -2424,19 +2727,19 @@ "api_key": [] } ] - } - }, - "/api/v1/books/{book_id}/progress": { - "get": { + }, + "patch": { "tags": [ - "Reading Progress" + "Books" ], - "summary": "Get reading progress for a book", - "operationId": "get_reading_progress", + "summary": "Update book core fields (title, number)", + "description": "Partially updates book_metadata fields. Only provided fields will be updated.\nAbsent fields are unchanged. Explicitly null fields will be cleared.\nWhen a field is set to a non-null value, it is automatically locked.", + "operationId": "patch_book", "parameters": [ { "name": "book_id", "in": "path", + "description": "Book ID", "required": true, "schema": { "type": "string", @@ -2444,46 +2747,57 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchBookRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Reading progress retrieved", + "description": "Book updated successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReadProgressResponse" + "$ref": "#/components/schemas/BookUpdateResponse" } } } }, - "401": { - "description": "Unauthorized" - }, "403": { "description": "Forbidden" }, "404": { - "description": "Progress not found" + "description": "Book not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] } ] - }, - "put": { + } + }, + "/api/v1/books/{book_id}/adjacent": { + "get": { "tags": [ - "Reading Progress" + "Books" ], - "summary": "Update reading progress for a book", - "operationId": "update_reading_progress", + "summary": "Get adjacent books in the same series", + "description": "Returns the previous and next books relative to the requested book,\nordered by book number within the series.", + "operationId": "get_adjacent_books", "parameters": [ { "name": "book_id", "in": "path", + "description": "Book ID", "required": true, "schema": { "type": "string", @@ -2491,56 +2805,44 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateProgressRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Progress updated successfully", + "description": "Adjacent books", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReadProgressResponse" + "$ref": "#/components/schemas/AdjacentBooksResponse" } } } }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, "404": { "description": "Book not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] } ] - }, - "delete": { + } + }, + "/api/v1/books/{book_id}/analyze": { + "post": { "tags": [ - "Reading Progress" + "Scans" ], - "summary": "Delete reading progress for a book", - "operationId": "delete_reading_progress", + "summary": "Trigger analysis of a single book (force reanalysis)", + "description": "# Permission Required\n- `books:write`\n\n# Behavior\nEnqueues an AnalyzeBook task with force=true.\nReturns immediately with a task_id to track progress.", + "operationId": "trigger_book_analysis", "parameters": [ { "name": "book_id", "in": "path", + "description": "Book ID", "required": true, "schema": { "type": "string", @@ -2549,14 +2851,21 @@ } ], "responses": { - "204": { - "description": "Progress deleted successfully" - }, - "401": { - "description": "Unauthorized" + "200": { + "description": "Analysis task enqueued successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTaskResponse" + } + } + } }, "403": { - "description": "Forbidden" + "description": "Permission denied" + }, + "404": { + "description": "Book not found" } }, "security": [ @@ -2569,17 +2878,19 @@ ] } }, - "/api/v1/books/{book_id}/read": { + "/api/v1/books/{book_id}/analyze-unanalyzed": { "post": { "tags": [ - "Reading Progress" + "Scans" ], - "summary": "Mark a book as read (completed)", - "operationId": "mark_book_as_read", + "summary": "Trigger analysis of a book if not already analyzed", + "description": "# Permission Required\n- `books:write`\n\n# Behavior\nEnqueues an AnalyzeBook task with force=false if the book has not been analyzed yet.\nReturns 400 if the book is already analyzed.\nReturns immediately with a task_id to track progress.", + "operationId": "trigger_book_unanalyzed_analysis", "parameters": [ { "name": "book_id", "in": "path", + "description": "Book ID", "required": true, "schema": { "type": "string", @@ -2589,20 +2900,20 @@ ], "responses": { "200": { - "description": "Book marked as read", + "description": "Analysis task enqueued successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReadProgressResponse" + "$ref": "#/components/schemas/CreateTaskResponse" } } } }, - "401": { - "description": "Unauthorized" + "400": { + "description": "Book is already analyzed" }, "403": { - "description": "Forbidden" + "description": "Permission denied" }, "404": { "description": "Book not found" @@ -2618,14 +2929,14 @@ ] } }, - "/api/v1/books/{book_id}/retry": { + "/api/v1/books/{book_id}/cover": { "post": { "tags": [ "Books" ], - "summary": "Retry failed operations for a specific book", - "description": "Enqueues appropriate tasks based on the error types present or specified.\nFor parser/metadata/page_extraction errors, enqueues an AnalyzeBook task.\nFor thumbnail errors, enqueues a GenerateThumbnail task.", - "operationId": "retry_book_errors", + "summary": "Upload a custom cover image for a book", + "description": "Accepts a multipart form with an image file. The image will be stored\nin the uploads directory and used as the book's cover/thumbnail.", + "operationId": "upload_book_cover", "parameters": [ { "name": "book_id", @@ -2640,37 +2951,15 @@ ], "requestBody": { "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RetryBookErrorsRequest" - }, - "example": { - "errorTypes": [ - "parser", - "thumbnail" - ] - } - } - }, - "required": true + "multipart/form-data": {} + } }, "responses": { "200": { - "description": "Retry tasks enqueued", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RetryErrorsResponse" - }, - "example": { - "message": "Enqueued 1 analysis task and 1 thumbnail task", - "tasksEnqueued": 2 - } - } - } + "description": "Cover uploaded successfully" }, "400": { - "description": "Book has no errors to retry" + "description": "Bad request - no image file provided or invalid image" }, "403": { "description": "Forbidden" @@ -2689,14 +2978,14 @@ ] } }, - "/api/v1/books/{book_id}/thumbnail": { + "/api/v1/books/{book_id}/file": { "get": { "tags": [ - "books" + "Books" ], - "summary": "Get thumbnail/cover image for a book", - "description": "Extracts the first page and resizes it to a thumbnail (max 400px width/height).\nSupports HTTP conditional caching with ETag and Last-Modified headers,\nreturning 304 Not Modified when the client has a valid cached copy.", - "operationId": "get_book_thumbnail", + "summary": "Download book file", + "description": "Streams the original book file (CBZ, CBR, EPUB, PDF) for download.\nUsed by OPDS clients for acquisition links.", + "operationId": "get_book_file", "parameters": [ { "name": "book_id", @@ -2711,14 +3000,11 @@ ], "responses": { "200": { - "description": "Thumbnail image", + "description": "Book file", "content": { - "image/jpeg": {} + "application/octet-stream": {} } }, - "304": { - "description": "Not modified (client cache is valid)" - }, "403": { "description": "Forbidden" }, @@ -2736,14 +3022,14 @@ ] } }, - "/api/v1/books/{book_id}/thumbnail/generate": { - "post": { + "/api/v1/books/{book_id}/metadata": { + "put": { "tags": [ - "Thumbnails" + "Books" ], - "summary": "Generate thumbnail for a single book", - "description": "Queues a task to generate (or regenerate) the thumbnail for a specific book.\n\n# Permission Required\n- `tasks:write`", - "operationId": "generate_book_thumbnail", + "summary": "Replace all book metadata (PUT)", + "description": "Completely replaces all metadata fields. Omitted or null fields will be cleared.\nIf no metadata record exists, one will be created.", + "operationId": "replace_book_metadata", "parameters": [ { "name": "book_id", @@ -2760,7 +3046,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ForceRequest" + "$ref": "#/components/schemas/ReplaceBookMetadataRequest" } } }, @@ -2768,17 +3054,17 @@ }, "responses": { "200": { - "description": "Thumbnail generation task queued", + "description": "Metadata replaced successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTaskResponse" + "$ref": "#/components/schemas/BookMetadataResponse" } } } }, "403": { - "description": "Permission denied" + "description": "Forbidden" }, "404": { "description": "Book not found" @@ -2786,25 +3072,25 @@ }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] } ] - } - }, - "/api/v1/books/{book_id}/unread": { - "post": { + }, + "patch": { "tags": [ - "Reading Progress" + "Books" ], - "summary": "Mark a book as unread (removes reading progress)", - "operationId": "mark_book_as_unread", + "summary": "Partially update book metadata (PATCH)", + "description": "Only provided fields will be updated. Absent fields are unchanged.\nExplicitly null fields will be cleared.\nIf no metadata record exists, one will be created with the provided fields.", + "operationId": "patch_book_metadata", "parameters": [ { "name": "book_id", "in": "path", + "description": "Book ID", "required": true, "schema": { "type": "string", @@ -2812,20 +3098,37 @@ } } ], - "responses": { - "204": { - "description": "Book marked as unread" + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchBookMetadataRequest" + } + } }, - "401": { - "description": "Unauthorized" + "required": true + }, + "responses": { + "200": { + "description": "Metadata updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookMetadataResponse" + } + } + } }, "403": { "description": "Forbidden" + }, + "404": { + "description": "Book not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -2833,68 +3136,103 @@ ] } }, - "/api/v1/duplicates": { + "/api/v1/books/{book_id}/metadata/locks": { "get": { "tags": [ - "Duplicates" + "Books" + ], + "summary": "Get book metadata lock states", + "description": "Returns which metadata fields are locked (protected from automatic updates).", + "operationId": "get_book_metadata_locks", + "parameters": [ + { + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "summary": "List all duplicate book groups", - "description": "# Permission Required\n- `books:read`", - "operationId": "list_duplicates", "responses": { "200": { - "description": "List of duplicate groups", + "description": "Lock states retrieved successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListDuplicatesResponse" + "$ref": "#/components/schemas/BookMetadataLocks" } } } }, "403": { - "description": "Permission denied" + "description": "Forbidden" + }, + "404": { + "description": "Book or metadata not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] } ] - } - }, - "/api/v1/duplicates/scan": { - "post": { + }, + "put": { "tags": [ - "Duplicates" + "Books" ], - "summary": "Trigger a manual duplicate detection scan", - "description": "# Permission Required\n- `books:write`", - "operationId": "trigger_duplicate_scan", + "summary": "Update book metadata lock states", + "description": "Updates which metadata fields are locked. Only provided fields will be updated.", + "operationId": "update_book_metadata_locks", + "parameters": [ + { + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateBookMetadataLocksRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Scan triggered", + "description": "Lock states updated successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TriggerDuplicateScanResponse" + "$ref": "#/components/schemas/BookMetadataLocks" } } } }, "403": { - "description": "Permission denied" + "description": "Forbidden" }, - "409": { - "description": "Scan already in progress" + "404": { + "description": "Book or metadata not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -2902,40 +3240,56 @@ ] } }, - "/api/v1/duplicates/{duplicate_id}": { - "delete": { + "/api/v1/books/{book_id}/pages/{page_number}": { + "get": { "tags": [ - "Duplicates" + "Pages" ], - "summary": "Delete a specific duplicate group (does not delete books, just the duplicate record)", - "description": "# Permission Required\n- `books:write`", - "operationId": "delete_duplicate_group", + "summary": "Get page image from a book", + "description": "Extracts and serves the image for a specific page from CBZ/CBR/EPUB/PDF.\nFor PDF pages, supports HTTP conditional caching with ETag and Last-Modified\nheaders, returning 304 Not Modified when the client has a valid cached copy.", + "operationId": "get_page_image", "parameters": [ { - "name": "duplicate_id", + "name": "book_id", "in": "path", - "description": "Duplicate group ID", + "description": "Book ID", "required": true, "schema": { "type": "string", "format": "uuid" } + }, + { + "name": "page_number", + "in": "path", + "description": "Page number (1-indexed)", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } } ], "responses": { - "204": { - "description": "Duplicate group deleted" + "200": { + "description": "Page image", + "content": { + "image/jpeg": {} + } + }, + "304": { + "description": "Not modified (client cache is valid)" }, "403": { - "description": "Permission denied" + "description": "Forbidden" }, "404": { - "description": "Duplicate group not found" + "description": "Book or page not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -2943,19 +3297,33 @@ ] } }, - "/api/v1/events/stream": { + "/api/v1/books/{book_id}/progress": { "get": { "tags": [ - "Events" + "Reading Progress" + ], + "summary": "Get reading progress for a book", + "operationId": "get_reading_progress", + "parameters": [ + { + "name": "book_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "summary": "Subscribe to real-time entity change events via SSE", - "description": "Clients can subscribe to this endpoint to receive real-time notifications\nabout entity changes (books, series, libraries) happening in the system.\n\n## Authentication\nRequires valid authentication with `LibrariesRead` permission.\n\n## Event Format\nEvents are sent as JSON-encoded `EntityChangeEvent` objects with the following structure:\n```json\n{\n \"type\": \"book_created\",\n \"book_id\": \"uuid\",\n \"series_id\": \"uuid\",\n \"library_id\": \"uuid\",\n \"timestamp\": \"2024-01-06T12:00:00Z\",\n \"user_id\": \"uuid\"\n}\n```\n\n## Keep-Alive\nA keep-alive message is sent every 15 seconds to prevent connection timeout.", - "operationId": "entity_events_stream", "responses": { "200": { - "description": "SSE stream of entity change events", + "description": "Reading progress retrieved", "content": { - "text/event-stream": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadProgressResponse" + } + } } }, "401": { @@ -2963,118 +3331,108 @@ }, "403": { "description": "Forbidden" + }, + "404": { + "description": "Progress not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - } - }, - "/api/v1/filesystem/browse": { - "get": { + }, + "put": { "tags": [ - "Filesystem" + "Reading Progress" ], - "summary": "Browse filesystem directories", - "description": "Returns a list of directories and files in the specified path", - "operationId": "browse_filesystem", + "summary": "Update reading progress for a book", + "operationId": "update_reading_progress", "parameters": [ { - "name": "path", - "in": "query", - "description": "Path to browse (defaults to user's home directory)", - "required": false, + "name": "book_id", + "in": "path", + "required": true, "schema": { - "type": [ - "string", - "null" - ] + "type": "string", + "format": "uuid" } } ], - "responses": { - "200": { - "description": "Directory contents", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BrowseResponse" - } + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProgressRequest" } } }, - "400": { - "description": "Invalid path", + "required": true + }, + "responses": { + "200": { + "description": "Progress updated successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ReadProgressResponse" } } } }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Forbidden", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } + "description": "Forbidden" + }, + "404": { + "description": "Book not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - } - }, - "/api/v1/filesystem/drives": { - "get": { + }, + "delete": { "tags": [ - "Filesystem" + "Reading Progress" ], - "summary": "Get system drives/volumes", - "description": "Returns a list of available drives or mount points on the system", - "operationId": "list_drives", - "responses": { - "200": { - "description": "Available drives", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileSystemEntry" - } - } - } + "summary": "Delete reading progress for a book", + "operationId": "delete_reading_progress", + "parameters": [ + { + "name": "book_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" } + } + ], + "responses": { + "204": { + "description": "Progress deleted successfully" + }, + "401": { + "description": "Unauthorized" }, "403": { - "description": "Forbidden", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } + "description": "Forbidden" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -3082,55 +3440,48 @@ ] } }, - "/api/v1/genres": { - "get": { + "/api/v1/books/{book_id}/read": { + "post": { "tags": [ - "Genres" + "Reading Progress" ], - "summary": "List all genres", - "operationId": "list_genres", + "summary": "Mark a book as read (completed)", + "operationId": "mark_book_as_read", "parameters": [ { - "name": "page", - "in": "query", - "description": "Page number (1-indexed, default 1)", - "required": false, + "name": "book_id", + "in": "path", + "required": true, "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, - { - "name": "pageSize", - "in": "query", - "description": "Number of items per page (default 50, max 500)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string", + "format": "uuid" } } ], "responses": { "200": { - "description": "List of all genres", + "description": "Book marked as read", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse_GenreDto" + "$ref": "#/components/schemas/ReadProgressResponse" } } } }, + "401": { + "description": "Unauthorized" + }, "403": { "description": "Forbidden" + }, + "404": { + "description": "Book not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -3138,26 +3489,65 @@ ] } }, - "/api/v1/genres/cleanup": { + "/api/v1/books/{book_id}/retry": { "post": { "tags": [ - "Genres" + "Books" ], - "summary": "Delete all unused genres (genres with no series linked)", - "operationId": "cleanup_genres", + "summary": "Retry failed operations for a specific book", + "description": "Enqueues appropriate tasks based on the error types present or specified.\nFor parser/metadata/page_extraction errors, enqueues an AnalyzeBook task.\nFor thumbnail errors, enqueues a GenerateThumbnail task.", + "operationId": "retry_book_errors", + "parameters": [ + { + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RetryBookErrorsRequest" + }, + "example": { + "errorTypes": [ + "parser", + "thumbnail" + ] + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Cleanup completed", + "description": "Retry tasks enqueued", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TaxonomyCleanupResponse" + "$ref": "#/components/schemas/RetryErrorsResponse" + }, + "example": { + "message": "Enqueued 1 analysis task and 1 thumbnail task", + "tasksEnqueued": 2 } } } }, + "400": { + "description": "Book has no errors to retry" + }, "403": { - "description": "Forbidden - admin only" + "description": "Forbidden" + }, + "404": { + "description": "Book not found" } }, "security": [ @@ -3170,18 +3560,19 @@ ] } }, - "/api/v1/genres/{genre_id}": { - "delete": { + "/api/v1/books/{book_id}/thumbnail": { + "get": { "tags": [ - "Genres" + "books" ], - "summary": "Delete a genre from the taxonomy (admin only)", - "operationId": "delete_genre", + "summary": "Get thumbnail/cover image for a book", + "description": "Extracts the first page and resizes it to a thumbnail (max 400px width/height).\nSupports HTTP conditional caching with ETag and Last-Modified headers,\nreturning 304 Not Modified when the client has a valid cached copy.", + "operationId": "get_book_thumbnail", "parameters": [ { - "name": "genre_id", + "name": "book_id", "in": "path", - "description": "Genre ID", + "description": "Book ID", "required": true, "schema": { "type": "string", @@ -3190,14 +3581,20 @@ } ], "responses": { - "204": { - "description": "Genre deleted" + "200": { + "description": "Thumbnail image", + "content": { + "image/jpeg": {} + } + }, + "304": { + "description": "Not modified (client cache is valid)" }, "403": { - "description": "Forbidden - admin only" + "description": "Forbidden" }, "404": { - "description": "Genre not found" + "description": "Book not found" } }, "security": [ @@ -3210,69 +3607,88 @@ ] } }, - "/api/v1/info": { - "get": { + "/api/v1/books/{book_id}/thumbnail/generate": { + "post": { "tags": [ - "Info" + "Thumbnails" ], - "summary": "Get application information", - "description": "Returns the application name and version.\nThis endpoint is public (no authentication required).", - "operationId": "get_app_info", + "summary": "Generate thumbnail for a single book", + "description": "Queues a task to generate (or regenerate) the thumbnail for a specific book.\n\n# Permission Required\n- `tasks:write`", + "operationId": "generate_book_thumbnail", + "parameters": [ + { + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForceRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Application info", + "description": "Thumbnail generation task queued", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AppInfoDto" + "$ref": "#/components/schemas/CreateTaskResponse" } } } + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Book not found" } - } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] } }, - "/api/v1/libraries": { - "get": { + "/api/v1/books/{book_id}/unread": { + "post": { "tags": [ - "Libraries" + "Reading Progress" ], - "summary": "List all libraries", - "operationId": "list_libraries", + "summary": "Mark a book as unread (removes reading progress)", + "operationId": "mark_book_as_unread", "parameters": [ { - "name": "page", - "in": "query", - "description": "Page number (1-indexed, default 1)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, - { - "name": "pageSize", - "in": "query", - "description": "Number of items per page (default 50, max 500)", - "required": false, + "name": "book_id", + "in": "path", + "required": true, "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string", + "format": "uuid" } } ], "responses": { - "200": { - "description": "List of libraries", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaginatedResponse_LibraryDto" - } - } - } + "204": { + "description": "Book marked as unread" + }, + "401": { + "description": "Unauthorized" }, "403": { "description": "Forbidden" @@ -3280,50 +3696,40 @@ }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - }, - "post": { + } + }, + "/api/v1/duplicates": { + "get": { "tags": [ - "Libraries" + "Duplicates" ], - "summary": "Create a new library", - "operationId": "create_library", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateLibraryRequest" - } - } - }, - "required": true - }, + "summary": "List all duplicate book groups", + "description": "# Permission Required\n- `books:read`", + "operationId": "list_duplicates", "responses": { - "201": { - "description": "Library created", + "200": { + "description": "List of duplicate groups", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LibraryDto" + "$ref": "#/components/schemas/ListDuplicatesResponse" } } } }, - "400": { - "description": "Invalid request" - }, "403": { - "description": "Forbidden" + "description": "Permission denied" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -3331,45 +3737,35 @@ ] } }, - "/api/v1/libraries/preview-scan": { + "/api/v1/duplicates/scan": { "post": { "tags": [ - "Libraries" + "Duplicates" ], - "summary": "Preview scan a path with a given strategy", - "description": "This endpoint allows users to preview how a scanning strategy would organize\nfiles without actually creating a library or importing anything. Useful for\ntesting strategy configurations before committing to them.", - "operationId": "preview_scan", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PreviewScanRequest" - } - } - }, - "required": true - }, + "summary": "Trigger a manual duplicate detection scan", + "description": "# Permission Required\n- `books:write`", + "operationId": "trigger_duplicate_scan", "responses": { "200": { - "description": "Preview scan results", + "description": "Scan triggered", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PreviewScanResponse" + "$ref": "#/components/schemas/TriggerDuplicateScanResponse" } } } }, - "400": { - "description": "Invalid request or path" - }, "403": { - "description": "Forbidden" + "description": "Permission denied" + }, + "409": { + "description": "Scan already in progress" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -3377,18 +3773,19 @@ ] } }, - "/api/v1/libraries/{library_id}": { - "get": { + "/api/v1/duplicates/{duplicate_id}": { + "delete": { "tags": [ - "Libraries" + "Duplicates" ], - "summary": "Get library by ID", - "operationId": "get_library", + "summary": "Delete a specific duplicate group (does not delete books, just the duplicate record)", + "description": "# Permission Required\n- `books:write`", + "operationId": "delete_duplicate_group", "parameters": [ { - "name": "library_id", + "name": "duplicate_id", "in": "path", - "description": "Library ID", + "description": "Duplicate group ID", "required": true, "schema": { "type": "string", @@ -3397,56 +3794,46 @@ } ], "responses": { - "200": { - "description": "Library details", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LibraryDto" - } - } - } + "204": { + "description": "Duplicate group deleted" + }, + "403": { + "description": "Permission denied" }, "404": { - "description": "Library not found" + "description": "Duplicate group not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - }, - "delete": { + } + }, + "/api/v1/events/stream": { + "get": { "tags": [ - "Libraries" - ], - "summary": "Delete a library", - "operationId": "delete_library", - "parameters": [ - { - "name": "library_id", - "in": "path", - "description": "Library ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Events" ], + "summary": "Subscribe to real-time entity change events via SSE", + "description": "Clients can subscribe to this endpoint to receive real-time notifications\nabout entity changes (books, series, libraries) happening in the system.\n\n## Authentication\nRequires valid authentication with `LibrariesRead` permission.\n\n## Event Format\nEvents are sent as JSON-encoded `EntityChangeEvent` objects with the following structure:\n```json\n{\n \"type\": \"book_created\",\n \"book_id\": \"uuid\",\n \"series_id\": \"uuid\",\n \"library_id\": \"uuid\",\n \"timestamp\": \"2024-01-06T12:00:00Z\",\n \"user_id\": \"uuid\"\n}\n```\n\n## Keep-Alive\nA keep-alive message is sent every 15 seconds to prevent connection timeout.", + "operationId": "entity_events_stream", "responses": { - "204": { - "description": "Library deleted" + "200": { + "description": "SSE stream of entity change events", + "content": { + "text/event-stream": {} + } + }, + "401": { + "description": "Unauthorized" }, "403": { "description": "Forbidden" - }, - "404": { - "description": "Library not found" } }, "security": [ @@ -3457,51 +3844,60 @@ "api_key": [] } ] - }, - "patch": { + } + }, + "/api/v1/filesystem/browse": { + "get": { "tags": [ - "Libraries" + "Filesystem" ], - "summary": "Update a library (partial update)", - "operationId": "update_library", + "summary": "Browse filesystem directories", + "description": "Returns a list of directories and files in the specified path", + "operationId": "browse_filesystem", "parameters": [ { - "name": "library_id", - "in": "path", - "description": "Library ID", - "required": true, + "name": "path", + "in": "query", + "description": "Path to browse (defaults to user's home directory)", + "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": [ + "string", + "null" + ] } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateLibraryRequest" + "responses": { + "200": { + "description": "Directory contents", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrowseResponse" + } } } }, - "required": true - }, - "responses": { - "200": { - "description": "Library updated", + "400": { + "description": "Invalid path", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LibraryDto" + "$ref": "#/components/schemas/ErrorResponse" } } } }, "403": { - "description": "Forbidden" - }, - "404": { - "description": "Library not found" + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } }, "security": [ @@ -3514,47 +3910,42 @@ ] } }, - "/api/v1/libraries/{library_id}/analyze": { - "post": { + "/api/v1/filesystem/drives": { + "get": { "tags": [ - "Scans" - ], - "summary": "Trigger forced analysis of all books in a library", - "description": "# Permission Required\n- `libraries:write`\n\n# Behavior\nEnqueues AnalyzeBook tasks (with force=true) for ALL books in the library.\nThis forces re-analysis even for books that have been analyzed before.\nReturns immediately with a task_id to track progress.", - "operationId": "trigger_library_analysis", - "parameters": [ - { - "name": "library_id", - "in": "path", - "description": "Library ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Filesystem" ], + "summary": "Get system drives/volumes", + "description": "Returns a list of available drives or mount points on the system", + "operationId": "list_drives", "responses": { "200": { - "description": "Analysis tasks enqueued successfully", + "description": "Available drives", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTaskResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/FileSystemEntry" + } } } } }, "403": { - "description": "Permission denied" - }, - "404": { - "description": "Library not found" + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -3562,47 +3953,55 @@ ] } }, - "/api/v1/libraries/{library_id}/analyze-unanalyzed": { - "post": { + "/api/v1/genres": { + "get": { "tags": [ - "Scans" + "Genres" ], - "summary": "Trigger analysis of unanalyzed books in a library", - "description": "# Permission Required\n- `libraries:write`\n\n# Behavior\nEnqueues AnalyzeBook tasks (with force=false) for books that have not been analyzed yet.\nThis is useful for recovering from failures or analyzing newly discovered books.\nReturns immediately with a task_id to track progress.", - "operationId": "trigger_library_unanalyzed_analysis", + "summary": "List all genres", + "operationId": "list_genres", "parameters": [ { - "name": "library_id", - "in": "path", - "description": "Library ID", - "required": true, + "name": "page", + "in": "query", + "description": "Page number (1-indexed, default 1)", + "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "pageSize", + "in": "query", + "description": "Number of items per page (default 50, max 500)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 } } ], "responses": { "200": { - "description": "Analysis tasks enqueued successfully", + "description": "List of all genres", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTaskResponse" + "$ref": "#/components/schemas/PaginatedResponse_GenreDto" } } } }, "403": { - "description": "Permission denied" - }, - "404": { - "description": "Library not found" + "description": "Forbidden" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -3610,60 +4009,26 @@ ] } }, - "/api/v1/libraries/{library_id}/books": { - "get": { + "/api/v1/genres/cleanup": { + "post": { "tags": [ - "Books" - ], - "summary": "List books in a specific library", - "operationId": "list_library_books", - "parameters": [ - { - "name": "library_id", - "in": "path", - "description": "Library ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "page", - "in": "path", - "description": "Page number (1-indexed, minimum 1)", - "required": true, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, - { - "name": "pageSize", - "in": "path", - "description": "Number of items per page (max 100, default 50)", - "required": true, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - } + "Genres" ], + "summary": "Delete all unused genres (genres with no series linked)", + "operationId": "cleanup_genres", "responses": { "200": { - "description": "Paginated list of books in library", + "description": "Cleanup completed", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse" + "$ref": "#/components/schemas/TaxonomyCleanupResponse" } } } }, "403": { - "description": "Forbidden" + "description": "Forbidden - admin only" } }, "security": [ @@ -3676,29 +4041,81 @@ ] } }, - "/api/v1/libraries/{library_id}/books/in-progress": { - "get": { + "/api/v1/genres/{genre_id}": { + "delete": { "tags": [ - "Books" + "Genres" ], - "summary": "List books with reading progress in a specific library (in-progress books)", - "operationId": "list_library_in_progress_books", + "summary": "Delete a genre from the taxonomy (admin only)", + "operationId": "delete_genre", "parameters": [ { - "name": "library_id", + "name": "genre_id", "in": "path", - "description": "Library ID", + "description": "Genre ID", "required": true, "schema": { "type": "string", "format": "uuid" } + } + ], + "responses": { + "204": { + "description": "Genre deleted" + }, + "403": { + "description": "Forbidden - admin only" + }, + "404": { + "description": "Genre not found" + } + }, + "security": [ + { + "jwt_bearer": [] }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/info": { + "get": { + "tags": [ + "Info" + ], + "summary": "Get application information", + "description": "Returns the application name and version.\nThis endpoint is public (no authentication required).", + "operationId": "get_app_info", + "responses": { + "200": { + "description": "Application info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppInfoDto" + } + } + } + } + } + } + }, + "/api/v1/libraries": { + "get": { + "tags": [ + "Libraries" + ], + "summary": "List all libraries", + "operationId": "list_libraries", + "parameters": [ { "name": "page", - "in": "path", - "description": "Page number (1-indexed, minimum 1)", - "required": true, + "in": "query", + "description": "Page number (1-indexed, default 1)", + "required": false, "schema": { "type": "integer", "format": "int64", @@ -3707,9 +4124,9 @@ }, { "name": "pageSize", - "in": "path", - "description": "Number of items per page (max 100, default 50)", - "required": true, + "in": "query", + "description": "Number of items per page (default 50, max 500)", + "required": false, "schema": { "type": "integer", "format": "int64", @@ -3719,11 +4136,11 @@ ], "responses": { "200": { - "description": "Paginated list of in-progress books in library", + "description": "List of libraries", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse" + "$ref": "#/components/schemas/PaginatedResponse_LibraryDto" } } } @@ -3740,60 +4157,37 @@ "api_key": [] } ] - } - }, - "/api/v1/libraries/{library_id}/books/on-deck": { - "get": { + }, + "post": { "tags": [ - "Books" + "Libraries" ], - "summary": "List on-deck books in a specific library", - "operationId": "list_library_on_deck_books", - "parameters": [ - { - "name": "library_id", - "in": "path", - "description": "Library ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "page", - "in": "path", - "description": "Page number (1-indexed, minimum 1)", - "required": true, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "summary": "Create a new library", + "operationId": "create_library", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateLibraryRequest" + } } }, - { - "name": "pageSize", - "in": "path", - "description": "Number of items per page (max 100, default 50)", - "required": true, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - } - ], + "required": true + }, "responses": { - "200": { - "description": "Paginated list of on-deck books in library", + "201": { + "description": "Library created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse" + "$ref": "#/components/schemas/LibraryDto" } } } }, + "400": { + "description": "Invalid request" + }, "403": { "description": "Forbidden" } @@ -3808,58 +4202,38 @@ ] } }, - "/api/v1/libraries/{library_id}/books/recently-added": { - "get": { + "/api/v1/libraries/preview-scan": { + "post": { "tags": [ - "Books" + "Libraries" ], - "summary": "List recently added books in a specific library", - "operationId": "list_library_recently_added_books", - "parameters": [ - { - "name": "library_id", - "in": "path", - "description": "Library ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "page", - "in": "path", - "description": "Page number (1-indexed, minimum 1)", - "required": true, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "summary": "Preview scan a path with a given strategy", + "description": "This endpoint allows users to preview how a scanning strategy would organize\nfiles without actually creating a library or importing anything. Useful for\ntesting strategy configurations before committing to them.", + "operationId": "preview_scan", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewScanRequest" + } } }, - { - "name": "pageSize", - "in": "path", - "description": "Number of items per page (max 100, default 50)", - "required": true, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - } - ], + "required": true + }, "responses": { "200": { - "description": "Paginated list of recently added books in library", + "description": "Preview scan results", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse" + "$ref": "#/components/schemas/PreviewScanResponse" } } } }, + "400": { + "description": "Invalid request or path" + }, "403": { "description": "Forbidden" } @@ -3874,16 +4248,17 @@ ] } }, - "/api/v1/libraries/{library_id}/books/recently-read": { - "get": { + "/api/v1/libraries/{id}/metadata/auto-match/task": { + "post": { "tags": [ - "Books" + "Plugin Actions" ], - "summary": "List recently read books in a specific library", - "operationId": "list_library_recently_read_books", + "summary": "Enqueue plugin auto-match tasks for all series in a library", + "description": "Creates background tasks to auto-match metadata for all series in a library using\nthe specified plugin. Each series gets its own task that runs asynchronously.", + "operationId": "enqueue_library_auto_match_tasks", "parameters": [ { - "name": "library_id", + "name": "id", "in": "path", "description": "Library ID", "required": true, @@ -3891,40 +4266,45 @@ "type": "string", "format": "uuid" } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnqueueLibraryAutoMatchRequest" + } + } }, - { - "name": "limit", - "in": "query", - "description": "Maximum number of books to return (default: 50)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - } - ], + "required": true + }, "responses": { "200": { - "description": "List of recently read books in library", + "description": "Tasks enqueued", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BookDto" - } + "$ref": "#/components/schemas/EnqueueAutoMatchResponse" } } } }, + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Forbidden" + "description": "No permission to edit series" + }, + "404": { + "description": "Library or plugin not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -3932,13 +4312,13 @@ ] } }, - "/api/v1/libraries/{library_id}/books/with-errors": { + "/api/v1/libraries/{library_id}": { "get": { "tags": [ - "Books" + "Libraries" ], - "summary": "List books with analysis errors in a specific library", - "operationId": "list_library_books_with_errors", + "summary": "Get library by ID", + "operationId": "get_library", "parameters": [ { "name": "library_id", @@ -3949,69 +4329,21 @@ "type": "string", "format": "uuid" } - }, - { - "name": "libraryId", - "in": "query", - "description": "Optional library filter", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "seriesId", - "in": "query", - "description": "Optional series filter", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "page", - "in": "query", - "description": "Page number (1-indexed, minimum 1)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, - { - "name": "pageSize", - "in": "query", - "description": "Number of items per page (max 100, default 50)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } } ], "responses": { "200": { - "description": "Paginated list of books with analysis errors in library", + "description": "Library details", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse" + "$ref": "#/components/schemas/LibraryDto" } } } }, - "403": { - "description": "Forbidden" + "404": { + "description": "Library not found" } }, "security": [ @@ -4022,15 +4354,13 @@ "api_key": [] } ] - } - }, - "/api/v1/libraries/{library_id}/purge-deleted": { + }, "delete": { "tags": [ "Libraries" ], - "summary": "Purge deleted books from a library", - "operationId": "purge_deleted_books", + "summary": "Delete a library", + "operationId": "delete_library", "parameters": [ { "name": "library_id", @@ -4044,17 +4374,8 @@ } ], "responses": { - "200": { - "description": "Number of books purged", - "content": { - "text/plain": { - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - } - } + "204": { + "description": "Library deleted" }, "403": { "description": "Forbidden" @@ -4071,16 +4392,13 @@ "api_key": [] } ] - } - }, - "/api/v1/libraries/{library_id}/scan": { - "post": { + }, + "patch": { "tags": [ - "Scans" + "Libraries" ], - "summary": "Trigger a library scan", - "description": "# Permission Required\n- `libraries:write`", - "operationId": "trigger_scan", + "summary": "Update a library (partial update)", + "operationId": "update_library", "parameters": [ { "name": "library_id", @@ -4091,44 +4409,39 @@ "type": "string", "format": "uuid" } - }, - { - "name": "mode", - "in": "query", - "description": "Scan mode: 'normal' or 'deep' (default: 'normal')", - "required": false, - "schema": { - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateLibraryRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Scan started successfully", + "description": "Library updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ScanStatusDto" + "$ref": "#/components/schemas/LibraryDto" } } } }, - "400": { - "description": "Invalid scan mode" - }, "403": { - "description": "Permission denied" + "description": "Forbidden" }, "404": { "description": "Library not found" - }, - "409": { - "description": "Scan already in progress" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -4136,14 +4449,14 @@ ] } }, - "/api/v1/libraries/{library_id}/scan-status": { - "get": { + "/api/v1/libraries/{library_id}/analyze": { + "post": { "tags": [ "Scans" ], - "summary": "Get scan status for a library", - "description": "# Permission Required\n- `libraries:read`", - "operationId": "get_scan_status", + "summary": "Trigger forced analysis of all books in a library", + "description": "# Permission Required\n- `libraries:write`\n\n# Behavior\nEnqueues AnalyzeBook tasks (with force=true) for ALL books in the library.\nThis forces re-analysis even for books that have been analyzed before.\nReturns immediately with a task_id to track progress.", + "operationId": "trigger_library_analysis", "parameters": [ { "name": "library_id", @@ -4158,11 +4471,11 @@ ], "responses": { "200": { - "description": "Scan status retrieved", + "description": "Analysis tasks enqueued successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ScanStatusDto" + "$ref": "#/components/schemas/CreateTaskResponse" } } } @@ -4171,7 +4484,7 @@ "description": "Permission denied" }, "404": { - "description": "No scan found for this library" + "description": "Library not found" } }, "security": [ @@ -4184,14 +4497,14 @@ ] } }, - "/api/v1/libraries/{library_id}/scan/cancel": { + "/api/v1/libraries/{library_id}/analyze-unanalyzed": { "post": { "tags": [ "Scans" ], - "summary": "Cancel a running scan", - "description": "# Permission Required\n- `libraries:write`", - "operationId": "cancel_scan", + "summary": "Trigger analysis of unanalyzed books in a library", + "description": "# Permission Required\n- `libraries:write`\n\n# Behavior\nEnqueues AnalyzeBook tasks (with force=false) for books that have not been analyzed yet.\nThis is useful for recovering from failures or analyzing newly discovered books.\nReturns immediately with a task_id to track progress.", + "operationId": "trigger_library_unanalyzed_analysis", "parameters": [ { "name": "library_id", @@ -4205,14 +4518,21 @@ } ], "responses": { - "204": { - "description": "Scan cancelled successfully" + "200": { + "description": "Analysis tasks enqueued successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTaskResponse" + } + } + } }, "403": { "description": "Permission denied" }, "404": { - "description": "No active scan found" + "description": "Library not found" } }, "security": [ @@ -4225,13 +4545,13 @@ ] } }, - "/api/v1/libraries/{library_id}/series": { + "/api/v1/libraries/{library_id}/books": { "get": { "tags": [ - "Series" + "Books" ], - "summary": "List series in a specific library with pagination", - "operationId": "list_library_series", + "summary": "List books in a specific library", + "operationId": "list_library_books", "parameters": [ { "name": "library_id", @@ -4245,9 +4565,9 @@ }, { "name": "page", - "in": "query", - "description": "Page number (1-indexed, default 1)", - "required": false, + "in": "path", + "description": "Page number (1-indexed, minimum 1)", + "required": true, "schema": { "type": "integer", "format": "int64", @@ -4256,77 +4576,85 @@ }, { "name": "pageSize", - "in": "query", + "in": "path", "description": "Number of items per page (max 100, default 50)", - "required": false, + "required": true, "schema": { "type": "integer", "format": "int64", "minimum": 0 } - }, - { - "name": "sort", - "in": "query", - "description": "Sort parameter (format: \"field,direction\" e.g. \"name,asc\")", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] + } + ], + "responses": { + "200": { + "description": "Paginated list of books in library", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse" + } + } } }, + "403": { + "description": "Forbidden" + } + }, + "security": [ { - "name": "genres", - "in": "query", - "description": "Filter by genres (comma-separated, AND logic - series must have ALL specified genres)", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } + "jwt_bearer": [] }, { - "name": "tags", - "in": "query", - "description": "Filter by tags (comma-separated, AND logic - series must have ALL specified tags)", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] + "api_key": [] + } + ] + } + }, + "/api/v1/libraries/{library_id}/books/in-progress": { + "get": { + "tags": [ + "Books" + ], + "summary": "List books with reading progress in a specific library (in-progress books)", + "operationId": "list_library_in_progress_books", + "parameters": [ + { + "name": "library_id", + "in": "path", + "description": "Library ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" } }, { - "name": "libraryId", - "in": "query", - "description": "Filter by library ID", - "required": false, + "name": "page", + "in": "path", + "description": "Page number (1-indexed, minimum 1)", + "required": true, "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" + "type": "integer", + "format": "int64", + "minimum": 0 } }, { - "name": "full", - "in": "query", - "description": "Return full series data including metadata, locks, genres, tags, alternate titles,\nexternal ratings, and external links. Default is false for backward compatibility.", - "required": false, + "name": "pageSize", + "in": "path", + "description": "Number of items per page (max 100, default 50)", + "required": true, "schema": { - "type": "boolean" + "type": "integer", + "format": "int64", + "minimum": 0 } } ], "responses": { "200": { - "description": "Paginated list of series in library (returns FullSeriesListResponse when full=true)", + "description": "Paginated list of in-progress books in library", "content": { "application/json": { "schema": { @@ -4349,13 +4677,13 @@ ] } }, - "/api/v1/libraries/{library_id}/series/in-progress": { + "/api/v1/libraries/{library_id}/books/on-deck": { "get": { "tags": [ - "Series" + "Books" ], - "summary": "List in-progress series in a specific library", - "operationId": "list_library_in_progress_series", + "summary": "List on-deck books in a specific library", + "operationId": "list_library_on_deck_books", "parameters": [ { "name": "library_id", @@ -4368,25 +4696,35 @@ } }, { - "name": "full", - "in": "query", - "description": "Return full series data including metadata, locks, genres, tags, etc.", - "required": false, + "name": "page", + "in": "path", + "description": "Page number (1-indexed, minimum 1)", + "required": true, "schema": { - "type": "boolean" + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "pageSize", + "in": "path", + "description": "Number of items per page (max 100, default 50)", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 } } ], "responses": { "200": { - "description": "List of in-progress series in library (returns Vec when full=true)", + "description": "Paginated list of on-deck books in library", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SeriesDto" - } + "$ref": "#/components/schemas/PaginatedResponse" } } } @@ -4405,13 +4743,13 @@ ] } }, - "/api/v1/libraries/{library_id}/series/recently-added": { + "/api/v1/libraries/{library_id}/books/recently-added": { "get": { "tags": [ - "Series" + "Books" ], - "summary": "List recently added series in a specific library", - "operationId": "list_library_recently_added_series", + "summary": "List recently added books in a specific library", + "operationId": "list_library_recently_added_books", "parameters": [ { "name": "library_id", @@ -4424,10 +4762,10 @@ } }, { - "name": "limit", - "in": "query", - "description": "Maximum number of series to return (default: 50)", - "required": false, + "name": "page", + "in": "path", + "description": "Page number (1-indexed, minimum 1)", + "required": true, "schema": { "type": "integer", "format": "int64", @@ -4435,38 +4773,24 @@ } }, { - "name": "libraryId", - "in": "query", - "description": "Filter by library ID (optional)", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "full", - "in": "query", - "description": "Return full series data including metadata, locks, genres, tags, etc.", - "required": false, + "name": "pageSize", + "in": "path", + "description": "Number of items per page (max 100, default 50)", + "required": true, "schema": { - "type": "boolean" + "type": "integer", + "format": "int64", + "minimum": 0 } } ], "responses": { "200": { - "description": "List of recently added series in library (returns Vec when full=true)", + "description": "Paginated list of recently added books in library", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SeriesDto" - } + "$ref": "#/components/schemas/PaginatedResponse" } } } @@ -4485,13 +4809,13 @@ ] } }, - "/api/v1/libraries/{library_id}/series/recently-updated": { + "/api/v1/libraries/{library_id}/books/recently-read": { "get": { "tags": [ - "Series" + "Books" ], - "summary": "List recently updated series in a specific library", - "operationId": "list_library_recently_updated_series", + "summary": "List recently read books in a specific library", + "operationId": "list_library_recently_read_books", "parameters": [ { "name": "library_id", @@ -4506,46 +4830,24 @@ { "name": "limit", "in": "query", - "description": "Maximum number of series to return (default: 50)", + "description": "Maximum number of books to return (default: 50)", "required": false, "schema": { "type": "integer", "format": "int64", "minimum": 0 } - }, - { - "name": "libraryId", - "in": "query", - "description": "Filter by library ID (optional)", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "full", - "in": "query", - "description": "Return full series data including metadata, locks, genres, tags, etc.", - "required": false, - "schema": { - "type": "boolean" - } } ], "responses": { "200": { - "description": "List of recently updated series in library (returns Vec when full=true)", + "description": "List of recently read books in library", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/SeriesDto" + "$ref": "#/components/schemas/BookDto" } } } @@ -4565,14 +4867,14 @@ ] } }, - "/api/v1/libraries/{library_id}/thumbnails/generate": { + "/api/v1/libraries/{library_id}/books/thumbnails/generate": { "post": { "tags": [ "Thumbnails" ], "summary": "Generate thumbnails for all books in a library", "description": "Queues a fan-out task that enqueues individual thumbnail generation tasks for each book in the library.\n\n# Permission Required\n- `tasks:write`", - "operationId": "generate_library_thumbnails", + "operationId": "generate_library_book_thumbnails", "parameters": [ { "name": "library_id", @@ -4623,32 +4925,48 @@ ] } }, - "/api/v1/metrics/inventory": { - "get": { + "/api/v1/libraries/{library_id}/purge-deleted": { + "delete": { "tags": [ - "Metrics" + "Libraries" + ], + "summary": "Purge deleted books from a library", + "operationId": "purge_deleted_books", + "parameters": [ + { + "name": "library_id", + "in": "path", + "description": "Library ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "summary": "Get inventory metrics (library/book counts)", - "description": "Returns counts and sizes for libraries, series, and books in the system.\nThis endpoint provides an inventory overview of your digital library.\n\n# Permission Required\n- `libraries:read` or admin status", - "operationId": "get_inventory_metrics", "responses": { "200": { - "description": "Inventory metrics retrieved successfully", + "description": "Number of books purged", "content": { - "application/json": { + "text/plain": { "schema": { - "$ref": "#/components/schemas/MetricsDto" + "type": "integer", + "format": "int64", + "minimum": 0 } } } }, "403": { - "description": "Permission denied" + "description": "Forbidden" + }, + "404": { + "description": "Library not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -4656,100 +4974,57 @@ ] } }, - "/api/v1/metrics/tasks": { - "get": { + "/api/v1/libraries/{library_id}/scan": { + "post": { "tags": [ - "Metrics" + "Scans" + ], + "summary": "Trigger a library scan", + "description": "# Permission Required\n- `libraries:write`", + "operationId": "trigger_scan", + "parameters": [ + { + "name": "library_id", + "in": "path", + "description": "Library ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "mode", + "in": "query", + "description": "Scan mode: 'normal' or 'deep' (default: 'normal')", + "required": false, + "schema": { + "type": "string" + } + } ], - "summary": "Get current task metrics", - "description": "Returns real-time task performance statistics including:\n- Summary metrics across all task types\n- Per-task-type breakdown with timing, throughput, and error rates\n- Queue health metrics (pending, processing, stale counts)\n\n# Permission Required\n- `libraries:read` or admin status", - "operationId": "get_task_metrics", "responses": { "200": { - "description": "Task metrics retrieved successfully", + "description": "Scan started successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TaskMetricsResponse" + "$ref": "#/components/schemas/ScanStatusDto" } } } }, + "400": { + "description": "Invalid scan mode" + }, "403": { "description": "Permission denied" }, - "503": { - "description": "Task metrics service not available" - } - }, - "security": [ - { - "bearer_auth": [] - }, - { - "api_key": [] - } - ] - }, - "delete": { - "tags": [ - "Metrics" - ], - "summary": "Delete all task metrics", - "description": "Permanently deletes all task metric records from the database\nand clears in-memory aggregates. This action cannot be undone.\n\n# Permission Required\n- Admin status required", - "operationId": "nuke_task_metrics", - "responses": { - "200": { - "description": "All metrics deleted successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MetricsNukeResponse" - } - } - } - }, - "403": { - "description": "Permission denied - admin required" - }, - "503": { - "description": "Task metrics service not available" - } - }, - "security": [ - { - "bearer_auth": [] - }, - { - "api_key": [] - } - ] - } - }, - "/api/v1/metrics/tasks/cleanup": { - "post": { - "tags": [ - "Metrics" - ], - "summary": "Trigger manual metrics cleanup", - "description": "Deletes metric records older than the configured retention period.\nThis operation normally runs automatically daily.\n\n# Permission Required\n- Admin status required", - "operationId": "trigger_metrics_cleanup", - "responses": { - "200": { - "description": "Cleanup completed successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MetricsCleanupResponse" - } - } - } - }, - "403": { - "description": "Permission denied - admin required" + "404": { + "description": "Library not found" }, - "503": { - "description": "Task metrics service not available" + "409": { + "description": "Scan already in progress" } }, "security": [ @@ -4762,63 +5037,33 @@ ] } }, - "/api/v1/metrics/tasks/history": { + "/api/v1/libraries/{library_id}/scan-status": { "get": { "tags": [ - "Metrics" + "Scans" ], - "summary": "Get task metrics history", - "description": "Returns historical task performance data for trend analysis.\nData is aggregated by hour or day depending on the granularity parameter.\n\n# Permission Required\n- `libraries:read` or admin status", - "operationId": "get_task_metrics_history", + "summary": "Get scan status for a library", + "description": "# Permission Required\n- `libraries:read`", + "operationId": "get_scan_status", "parameters": [ { - "name": "days", - "in": "query", - "description": "Number of days to retrieve (default: 7)", - "required": false, - "schema": { - "type": [ - "integer", - "null" - ], - "format": "int32" - }, - "example": 7 - }, - { - "name": "taskType", - "in": "query", - "description": "Filter by task type", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - }, - "example": "scan_library" - }, - { - "name": "granularity", - "in": "query", - "description": "Granularity: \"hour\" or \"day\" (default: hour)", - "required": false, + "name": "library_id", + "in": "path", + "description": "Library ID", + "required": true, "schema": { - "type": [ - "string", - "null" - ] - }, - "example": "hour" + "type": "string", + "format": "uuid" + } } ], "responses": { "200": { - "description": "Task metrics history retrieved successfully", + "description": "Scan status retrieved", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TaskMetricsHistoryResponse" + "$ref": "#/components/schemas/ScanStatusDto" } } } @@ -4826,43 +5071,8 @@ "403": { "description": "Permission denied" }, - "503": { - "description": "Task metrics service not available" - } - }, - "security": [ - { - "bearer_auth": [] - }, - { - "api_key": [] - } - ] - } - }, - "/api/v1/progress": { - "get": { - "tags": [ - "Reading Progress" - ], - "summary": "Get all reading progress for the authenticated user", - "operationId": "get_user_progress", - "responses": { - "200": { - "description": "User reading progress retrieved", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReadProgressListResponse" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" + "404": { + "description": "No scan found for this library" } }, "security": [ @@ -4875,56 +5085,35 @@ ] } }, - "/api/v1/scans/active": { - "get": { + "/api/v1/libraries/{library_id}/scan/cancel": { + "post": { "tags": [ "Scans" ], - "summary": "List all active scans", - "description": "# Permission Required\n- `libraries:read`", - "operationId": "list_active_scans", - "responses": { - "200": { - "description": "List of active scans", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ScanStatusDto" - } - } - } - } - }, - "403": { - "description": "Permission denied" - } - }, - "security": [ - { - "bearer_auth": [] - }, + "summary": "Cancel a running scan", + "description": "# Permission Required\n- `libraries:write`", + "operationId": "cancel_scan", + "parameters": [ { - "api_key": [] + "name": "library_id", + "in": "path", + "description": "Library ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } } - ] - } - }, - "/api/v1/scans/stream": { - "get": { - "tags": [ - "Scans" ], - "summary": "Stream scan progress updates via Server-Sent Events", - "description": "# Permission Required\n- `libraries:read`\n\n**DEPRECATED**: This endpoint is replaced by `/api/v1/tasks/stream` which provides\nreal-time updates for all task types including scans. This endpoint now filters\nthe task stream to only show scan_library tasks for backwards compatibility.", - "operationId": "scan_progress_stream", "responses": { - "200": { - "description": "SSE stream of scan progress updates" + "204": { + "description": "Scan cancelled successfully" }, "403": { "description": "Permission denied" + }, + "404": { + "description": "No active scan found" } }, "security": [ @@ -4937,14 +5126,24 @@ ] } }, - "/api/v1/series": { + "/api/v1/libraries/{library_id}/series": { "get": { "tags": [ "Series" ], - "summary": "List series with optional library filter and pagination", - "operationId": "list_series", + "summary": "List series in a specific library with pagination", + "operationId": "list_library_series", "parameters": [ + { + "name": "library_id", + "in": "path", + "description": "Library ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, { "name": "page", "in": "query", @@ -5028,7 +5227,7 @@ ], "responses": { "200": { - "description": "Paginated list of series (returns FullSeriesListResponse when full=true)", + "description": "Paginated list of series in library (returns FullSeriesListResponse when full=true)", "content": { "application/json": { "schema": { @@ -5051,24 +5250,21 @@ ] } }, - "/api/v1/series/in-progress": { + "/api/v1/libraries/{library_id}/series/in-progress": { "get": { "tags": [ "Series" ], - "summary": "List series with in-progress books (series that have at least one book with reading progress that is not completed)", - "operationId": "list_in_progress_series", + "summary": "List in-progress series in a specific library", + "operationId": "list_library_in_progress_series", "parameters": [ { - "name": "libraryId", - "in": "query", - "description": "Filter by library ID (optional)", - "required": false, + "name": "library_id", + "in": "path", + "description": "Library ID", + "required": true, "schema": { - "type": [ - "string", - "null" - ], + "type": "string", "format": "uuid" } }, @@ -5084,7 +5280,7 @@ ], "responses": { "200": { - "description": "List of in-progress series (returns Vec when full=true)", + "description": "List of in-progress series in library (returns Vec when full=true)", "content": { "application/json": { "schema": { @@ -5110,79 +5306,68 @@ ] } }, - "/api/v1/series/list": { - "post": { + "/api/v1/libraries/{library_id}/series/recently-added": { + "get": { "tags": [ "Series" ], - "summary": "List series with advanced filtering", - "description": "Supports complex filter conditions including nested AllOf/AnyOf logic,\ngenre/tag filtering with include/exclude, and more.\n\nPagination parameters (page, pageSize, sort) are passed as query parameters.\nFilter conditions are passed in the request body.", - "operationId": "list_series_filtered", + "summary": "List recently added series in a specific library", + "operationId": "list_library_recently_added_series", "parameters": [ { - "name": "page", - "in": "query", - "description": "Page number (1-indexed, minimum 1)", - "required": false, + "name": "library_id", + "in": "path", + "description": "Library ID", + "required": true, "schema": { - "type": "integer", - "format": "int64", - "default": 1, - "minimum": 1 + "type": "string", + "format": "uuid" } }, { - "name": "pageSize", + "name": "limit", "in": "query", - "description": "Number of items per page (max 500, default 50)", + "description": "Maximum number of series to return (default: 50)", "required": false, "schema": { "type": "integer", "format": "int64", - "default": 50, - "maximum": 500, - "minimum": 1 + "minimum": 0 } }, { - "name": "sort", + "name": "libraryId", "in": "query", - "description": "Sort field and direction (e.g., \"name,asc\" or \"createdAt,desc\")", + "description": "Filter by library ID (optional)", "required": false, "schema": { "type": [ "string", "null" - ] + ], + "format": "uuid" } }, { "name": "full", "in": "query", - "description": "Return full data including metadata, locks, and related entities.\nDefault is false for backward compatibility.", + "description": "Return full series data including metadata, locks, genres, tags, etc.", "required": false, "schema": { "type": "boolean" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SeriesListRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Paginated list of filtered series (returns FullSeriesListResponse when full=true)", + "description": "List of recently added series in library (returns Vec when full=true)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesDto" + } } } } @@ -5201,14 +5386,24 @@ ] } }, - "/api/v1/series/recently-added": { + "/api/v1/libraries/{library_id}/series/recently-updated": { "get": { "tags": [ "Series" ], - "summary": "List recently added series", - "operationId": "list_recently_added_series", + "summary": "List recently updated series in a specific library", + "operationId": "list_library_recently_updated_series", "parameters": [ + { + "name": "library_id", + "in": "path", + "description": "Library ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, { "name": "limit", "in": "query", @@ -5245,7 +5440,7 @@ ], "responses": { "200": { - "description": "List of recently added series (returns Vec when full=true)", + "description": "List of recently updated series in library (returns Vec when full=true)", "content": { "application/json": { "schema": { @@ -5271,69 +5466,57 @@ ] } }, - "/api/v1/series/recently-updated": { - "get": { + "/api/v1/libraries/{library_id}/series/thumbnails/generate": { + "post": { "tags": [ - "Series" + "Thumbnails" ], - "summary": "List recently updated series", - "operationId": "list_recently_updated_series", + "summary": "Generate thumbnails for all series in a library", + "description": "Queues a fan-out task that generates thumbnails for all series in the specified library.\n\n# Permission Required\n- `tasks:write`", + "operationId": "generate_library_series_thumbnails", "parameters": [ { - "name": "limit", - "in": "query", - "description": "Maximum number of series to return (default: 50)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, - { - "name": "libraryId", - "in": "query", - "description": "Filter by library ID (optional)", - "required": false, + "name": "library_id", + "in": "path", + "description": "Library ID", + "required": true, "schema": { - "type": [ - "string", - "null" - ], + "type": "string", "format": "uuid" } - }, - { - "name": "full", - "in": "query", - "description": "Return full series data including metadata, locks, genres, tags, etc.", - "required": false, - "schema": { - "type": "boolean" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForceRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "List of recently updated series (returns Vec when full=true)", + "description": "Series thumbnail generation task queued", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SeriesDto" - } + "$ref": "#/components/schemas/CreateTaskResponse" } } } }, "403": { - "description": "Forbidden" + "description": "Permission denied" + }, + "404": { + "description": "Library not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -5341,44 +5524,32 @@ ] } }, - "/api/v1/series/search": { - "post": { + "/api/v1/metrics/inventory": { + "get": { "tags": [ - "Series" + "Metrics" ], - "summary": "Search series by name", - "operationId": "search_series", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SearchSeriesRequest" - } - } - }, - "required": true - }, + "summary": "Get inventory metrics (library/book counts)", + "description": "Returns counts and sizes for libraries, series, and books in the system.\nThis endpoint provides an inventory overview of your digital library.\n\n# Permission Required\n- `libraries:read` or admin status", + "operationId": "get_inventory_metrics", "responses": { "200": { - "description": "Search results (returns Vec when full=true)", + "description": "Inventory metrics retrieved successfully", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SeriesDto" - } + "$ref": "#/components/schemas/MetricsDto" } } } }, "403": { - "description": "Forbidden" + "description": "Permission denied" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -5386,52 +5557,32 @@ ] } }, - "/api/v1/series/{series_id}": { + "/api/v1/metrics/plugins": { "get": { "tags": [ - "Series" - ], - "summary": "Get series by ID", - "operationId": "get_series", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "full", - "in": "query", - "description": "Return full series data including metadata, locks, genres, tags, etc.", - "required": false, - "schema": { - "type": "boolean" - } - } + "Metrics" ], + "summary": "Get plugin metrics", + "description": "Returns real-time performance statistics for all plugins including:\n- Summary metrics across all plugins\n- Per-plugin breakdown with timing, error rates, and health status\n- Per-method breakdown within each plugin\n\n# Permission Required\n- `libraries:read` or admin status", + "operationId": "get_plugin_metrics", "responses": { "200": { - "description": "Series details (returns FullSeriesResponse when full=true)", + "description": "Plugin metrics retrieved successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SeriesDto" + "$ref": "#/components/schemas/PluginMetricsResponse" } } } }, - "404": { - "description": "Series not found" + "403": { + "description": "Permission denied" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -5439,101 +5590,105 @@ ] } }, - "/api/v1/series/{series_id}/alternate-titles": { + "/api/v1/metrics/tasks": { "get": { "tags": [ - "Series" - ], - "summary": "Get alternate titles for a series", - "operationId": "get_series_alternate_titles", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Metrics" ], + "summary": "Get current task metrics", + "description": "Returns real-time task performance statistics including:\n- Summary metrics across all task types\n- Per-task-type breakdown with timing, throughput, and error rates\n- Queue health metrics (pending, processing, stale counts)\n\n# Permission Required\n- `libraries:read` or admin status", + "operationId": "get_task_metrics", "responses": { "200": { - "description": "List of alternate titles for the series", + "description": "Task metrics retrieved successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AlternateTitleListResponse" + "$ref": "#/components/schemas/TaskMetricsResponse" } } } }, "403": { - "description": "Forbidden" + "description": "Permission denied" }, - "404": { - "description": "Series not found" + "503": { + "description": "Task metrics service not available" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] }, - "post": { + "delete": { "tags": [ - "Series" + "Metrics" ], - "summary": "Add an alternate title to a series", - "operationId": "create_alternate_title", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateAlternateTitleRequest" + "summary": "Delete all task metrics", + "description": "Permanently deletes all task metric records from the database\nand clears in-memory aggregates. This action cannot be undone.\n\n# Permission Required\n- Admin status required", + "operationId": "nuke_task_metrics", + "responses": { + "200": { + "description": "All metrics deleted successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetricsNukeResponse" + } } } }, - "required": true + "403": { + "description": "Permission denied - admin required" + }, + "503": { + "description": "Task metrics service not available" + } }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/metrics/tasks/cleanup": { + "post": { + "tags": [ + "Metrics" + ], + "summary": "Trigger manual metrics cleanup", + "description": "Deletes metric records older than the configured retention period.\nThis operation normally runs automatically daily.\n\n# Permission Required\n- Admin status required", + "operationId": "trigger_metrics_cleanup", "responses": { - "201": { - "description": "Alternate title created", + "200": { + "description": "Cleanup completed successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AlternateTitleDto" + "$ref": "#/components/schemas/MetricsCleanupResponse" } } } }, "403": { - "description": "Forbidden" + "description": "Permission denied - admin required" }, - "404": { - "description": "Series not found" + "503": { + "description": "Task metrics service not available" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -5541,114 +5696,137 @@ ] } }, - "/api/v1/series/{series_id}/alternate-titles/{title_id}": { - "delete": { + "/api/v1/metrics/tasks/history": { + "get": { "tags": [ - "Series" + "Metrics" ], - "summary": "Delete an alternate title", - "operationId": "delete_alternate_title", + "summary": "Get task metrics history", + "description": "Returns historical task performance data for trend analysis.\nData is aggregated by hour or day depending on the granularity parameter.\n\n# Permission Required\n- `libraries:read` or admin status", + "operationId": "get_task_metrics_history", "parameters": [ { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, + "name": "days", + "in": "query", + "description": "Number of days to retrieve (default: 7)", + "required": false, "schema": { - "type": "string", - "format": "uuid" - } + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "example": 7 }, { - "name": "title_id", - "in": "path", - "description": "Alternate title ID", - "required": true, + "name": "taskType", + "in": "query", + "description": "Filter by task type", + "required": false, "schema": { - "type": "string", - "format": "uuid" - } + "type": [ + "string", + "null" + ] + }, + "example": "scan_library" + }, + { + "name": "granularity", + "in": "query", + "description": "Granularity: \"hour\" or \"day\" (default: hour)", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + }, + "example": "hour" } ], "responses": { - "204": { - "description": "Alternate title deleted" + "200": { + "description": "Task metrics history retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskMetricsHistoryResponse" + } + } + } }, "403": { - "description": "Forbidden" + "description": "Permission denied" }, - "404": { - "description": "Series or title not found" + "503": { + "description": "Task metrics service not available" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - }, - "patch": { + } + }, + "/api/v1/plugins/actions": { + "get": { "tags": [ - "Series" + "Plugin Actions" ], - "summary": "Update an alternate title", - "operationId": "update_alternate_title", + "summary": "Get available plugin actions for a scope", + "description": "Returns a list of available plugin actions for the specified scope.\nThis is used by the UI to populate dropdown menus with available plugins.", + "operationId": "get_plugin_actions", "parameters": [ { - "name": "series_id", - "in": "path", - "description": "Series ID", + "name": "scope", + "in": "query", + "description": "Scope to filter actions by (e.g., \"series:detail\", \"series:bulk\")", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } }, { - "name": "title_id", - "in": "path", - "description": "Alternate title ID", - "required": true, + "name": "libraryId", + "in": "query", + "description": "Optional library ID to filter plugins by. When provided, only plugins that\napply to this library (or all libraries) will be returned.", + "required": false, "schema": { - "type": "string", + "type": [ + "string", + "null" + ], "format": "uuid" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateAlternateTitleRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Alternate title updated", + "description": "Plugin actions retrieved", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AlternateTitleDto" + "$ref": "#/components/schemas/PluginActionsResponse" } } } }, - "403": { - "description": "Forbidden" + "400": { + "description": "Invalid scope" }, - "404": { - "description": "Series or title not found" + "401": { + "description": "Unauthorized" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -5656,19 +5834,19 @@ ] } }, - "/api/v1/series/{series_id}/analyze": { + "/api/v1/plugins/{id}/execute": { "post": { "tags": [ - "Scans" + "Plugin Actions" ], - "summary": "Trigger analysis of all books in a series", - "description": "# Permission Required\n- `series:write`\n\n# Behavior\nEnqueues an AnalyzeSeries task which will create individual AnalyzeBook tasks\nfor each book in the series. All books are analyzed with force=true.\nReturns immediately with a task_id to track progress.", - "operationId": "trigger_series_analysis", + "summary": "Execute a plugin action", + "description": "Invokes a plugin action and returns the result. Actions are typed by plugin type:\n- `metadata`: search, get, match (requires write permission for the content_type)\n- `ping`: health check (requires PluginsManage permission)", + "operationId": "execute_plugin", "parameters": [ { - "name": "series_id", + "name": "id", "in": "path", - "description": "Series ID", + "description": "Plugin ID", "required": true, "schema": { "type": "string", @@ -5676,22 +5854,38 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecutePluginRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Analysis task enqueued successfully", + "description": "Action executed", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTaskResponse" + "$ref": "#/components/schemas/ExecutePluginResponse" } } } }, + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Permission denied" + "description": "Insufficient permission for this action" }, "404": { - "description": "Series not found" + "description": "Plugin not found" } }, "security": [ @@ -5704,42 +5898,29 @@ ] } }, - "/api/v1/series/{series_id}/analyze-unanalyzed": { - "post": { + "/api/v1/progress": { + "get": { "tags": [ - "Scans" - ], - "summary": "Trigger analysis of unanalyzed books in a series", - "description": "# Permission Required\n- `series:write`\n\n# Behavior\nEnqueues AnalyzeBook tasks (with force=false) for books in the series that have not been analyzed yet.\nThis is useful for recovering from failures or analyzing newly discovered books.\nReturns immediately with a task_id to track progress.", - "operationId": "trigger_series_unanalyzed_analysis", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Reading Progress" ], + "summary": "Get all reading progress for the authenticated user", + "operationId": "get_user_progress", "responses": { "200": { - "description": "Analysis tasks enqueued successfully", + "description": "User reading progress retrieved", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTaskResponse" + "$ref": "#/components/schemas/ReadProgressListResponse" } } } }, - "403": { - "description": "Permission denied" + "401": { + "description": "Unauthorized" }, - "404": { - "description": "Series not found" + "403": { + "description": "Forbidden" } }, "security": [ @@ -5752,67 +5933,61 @@ ] } }, - "/api/v1/series/{series_id}/books": { + "/api/v1/scans/active": { "get": { "tags": [ - "Series" - ], - "summary": "Get books in a series", - "operationId": "get_series_books", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "includeDeleted", - "in": "query", - "description": "Include deleted books in the result", - "required": false, - "schema": { - "type": "boolean" - } - }, - { - "name": "full", - "in": "query", - "description": "Return full data including metadata and locks.\nDefault is false for backward compatibility.", - "required": false, - "schema": { - "type": "boolean" - } - } + "Scans" ], + "summary": "List all active scans", + "description": "# Permission Required\n- `libraries:read`", + "operationId": "list_active_scans", "responses": { "200": { - "description": "List of books in the series (returns Vec when full=true)", + "description": "List of active scans", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/BookDto" + "$ref": "#/components/schemas/ScanStatusDto" } } } } }, "403": { - "description": "Forbidden" + "description": "Permission denied" + } + }, + "security": [ + { + "bearer_auth": [] }, - "404": { - "description": "Series not found" + { + "api_key": [] + } + ] + } + }, + "/api/v1/scans/stream": { + "get": { + "tags": [ + "Scans" + ], + "summary": "Stream scan progress updates via Server-Sent Events", + "description": "# Permission Required\n- `libraries:read`\n\n**DEPRECATED**: This endpoint is replaced by `/api/v1/tasks/stream` which provides\nreal-time updates for all task types including scans. This endpoint now filters\nthe task stream to only show scan_library tasks for backwards compatibility.", + "operationId": "scan_progress_stream", + "responses": { + "200": { + "description": "SSE stream of scan progress updates" + }, + "403": { + "description": "Permission denied" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -5820,76 +5995,98 @@ ] } }, - "/api/v1/series/{series_id}/books/with-errors": { + "/api/v1/series": { "get": { "tags": [ - "Books" + "Series" ], - "summary": "List books with analysis errors in a specific series", - "operationId": "list_series_books_with_errors", + "summary": "List series with optional library filter and pagination", + "operationId": "list_series", "parameters": [ { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, + "name": "page", + "in": "query", + "description": "Page number (1-indexed, default 1)", + "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "format": "int64", + "minimum": 0 } }, { - "name": "libraryId", + "name": "pageSize", "in": "query", - "description": "Optional library filter", + "description": "Number of items per page (max 100, default 50)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "sort", + "in": "query", + "description": "Sort parameter (format: \"field,direction\" e.g. \"name,asc\")", "required": false, "schema": { "type": [ "string", "null" - ], - "format": "uuid" + ] } }, { - "name": "seriesId", + "name": "genres", "in": "query", - "description": "Optional series filter", + "description": "Filter by genres (comma-separated, AND logic - series must have ALL specified genres)", "required": false, "schema": { "type": [ "string", "null" - ], - "format": "uuid" + ] } }, { - "name": "page", + "name": "tags", "in": "query", - "description": "Page number (1-indexed, minimum 1)", + "description": "Filter by tags (comma-separated, AND logic - series must have ALL specified tags)", "required": false, "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": [ + "string", + "null" + ] } }, { - "name": "pageSize", + "name": "libraryId", "in": "query", - "description": "Number of items per page (max 100, default 50)", + "description": "Filter by library ID", "required": false, "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": [ + "string", + "null" + ], + "format": "uuid" + } + }, + { + "name": "full", + "in": "query", + "description": "Return full series data including metadata, locks, genres, tags, alternate titles,\nexternal ratings, and external links. Default is false for backward compatibility.", + "required": false, + "schema": { + "type": "boolean" } } ], "responses": { "200": { - "description": "Paginated list of books with analysis errors in series", + "description": "Paginated list of series (returns FullSeriesListResponse when full=true)", "content": { "application/json": { "schema": { @@ -5912,31 +6109,19 @@ ] } }, - "/api/v1/series/{series_id}/cover": { + "/api/v1/series/bulk/analyze": { "post": { "tags": [ - "Series" - ], - "summary": "Upload a custom cover/poster for a series", - "operationId": "upload_series_cover", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Bulk Operations" ], + "summary": "Bulk analyze multiple series", + "description": "Enqueues analysis tasks for all books in the specified series.\nSeries that don't exist are silently skipped.", + "operationId": "bulk_analyze_series", "requestBody": { - "description": "Multipart form with image file", "content": { - "multipart/form-data": { + "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/BulkAnalyzeSeriesRequest" } } }, @@ -5944,21 +6129,25 @@ }, "responses": { "200": { - "description": "Cover uploaded successfully" + "description": "Analysis tasks enqueued", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkAnalyzeResponse" + } + } + } }, - "400": { - "description": "Invalid image or request" + "401": { + "description": "Unauthorized" }, "403": { "description": "Forbidden" - }, - "404": { - "description": "Series not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -5966,30 +6155,19 @@ ] } }, - "/api/v1/series/{series_id}/cover/source": { - "patch": { + "/api/v1/series/bulk/read": { + "post": { "tags": [ - "Series" - ], - "summary": "Set which cover source to use for a series (partial update)", - "operationId": "set_series_cover_source", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Bulk Operations" ], + "summary": "Bulk mark multiple series as read", + "description": "Marks all books in the specified series as read for the authenticated user.\nSeries that don't exist are silently skipped.", + "operationId": "bulk_mark_series_as_read", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SelectCoverSourceRequest" + "$ref": "#/components/schemas/BulkSeriesRequest" } } }, @@ -5997,18 +6175,25 @@ }, "responses": { "200": { - "description": "Cover source updated successfully" + "description": "Series marked as read", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MarkReadResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" }, "403": { "description": "Forbidden" - }, - "404": { - "description": "Series not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -6016,46 +6201,45 @@ ] } }, - "/api/v1/series/{series_id}/covers": { - "get": { + "/api/v1/series/bulk/unread": { + "post": { "tags": [ - "Series" + "Bulk Operations" ], - "summary": "List all covers for a series", - "operationId": "list_series_covers", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" + "summary": "Bulk mark multiple series as unread", + "description": "Marks all books in the specified series as unread for the authenticated user.\nSeries that don't exist are silently skipped.", + "operationId": "bulk_mark_series_as_unread", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkSeriesRequest" + } } - } - ], + }, + "required": true + }, "responses": { "200": { - "description": "List of series covers", + "description": "Series marked as unread", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SeriesCoverListResponse" + "$ref": "#/components/schemas/MarkReadResponse" } } } }, + "401": { + "description": "Unauthorized" + }, "403": { "description": "Forbidden" - }, - "404": { - "description": "Series not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -6063,34 +6247,53 @@ ] } }, - "/api/v1/series/{series_id}/covers/selected": { - "delete": { + "/api/v1/series/in-progress": { + "get": { "tags": [ "Series" ], - "summary": "Reset series cover to default (deselect all custom covers)", - "operationId": "reset_series_cover", + "summary": "List series with in-progress books (series that have at least one book with reading progress that is not completed)", + "operationId": "list_in_progress_series", "parameters": [ { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, + "name": "libraryId", + "in": "query", + "description": "Filter by library ID (optional)", + "required": false, "schema": { - "type": "string", + "type": [ + "string", + "null" + ], "format": "uuid" } + }, + { + "name": "full", + "in": "query", + "description": "Return full series data including metadata, locks, genres, tags, etc.", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { - "204": { - "description": "Reset to default cover successfully" + "200": { + "description": "List of in-progress series (returns Vec when full=true)", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesDto" + } + } + } + } }, "403": { "description": "Forbidden" - }, - "404": { - "description": "Series not found" } }, "security": [ @@ -6103,104 +6306,85 @@ ] } }, - "/api/v1/series/{series_id}/covers/{cover_id}": { - "delete": { + "/api/v1/series/list": { + "post": { "tags": [ "Series" ], - "summary": "Delete a cover from a series", - "operationId": "delete_series_cover", + "summary": "List series with advanced filtering", + "description": "Supports complex filter conditions including nested AllOf/AnyOf logic,\ngenre/tag filtering with include/exclude, and more.\n\nPagination parameters (page, pageSize, sort) are passed as query parameters.\nFilter conditions are passed in the request body.", + "operationId": "list_series_filtered", "parameters": [ { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, + "name": "page", + "in": "query", + "description": "Page number (1-indexed, minimum 1)", + "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "format": "int64", + "default": 1, + "minimum": 1 } }, { - "name": "cover_id", - "in": "path", - "description": "Cover ID to delete", - "required": true, + "name": "pageSize", + "in": "query", + "description": "Number of items per page (max 500, default 50)", + "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "format": "int64", + "default": 50, + "maximum": 500, + "minimum": 1 } - } - ], - "responses": { - "204": { - "description": "Cover deleted successfully" - }, - "400": { - "description": "Cannot delete the only selected cover" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Series or cover not found" - } - }, - "security": [ - { - "jwt_bearer": [] }, { - "api_key": [] - } - ] - } - }, - "/api/v1/series/{series_id}/covers/{cover_id}/image": { - "get": { - "tags": [ - "Series" - ], - "summary": "Get a specific cover image for a series", - "description": "Supports HTTP conditional caching with ETag and Last-Modified headers,\nreturning 304 Not Modified when the client has a valid cached copy.", - "operationId": "get_series_cover_image", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, + "name": "sort", + "in": "query", + "description": "Sort field and direction (e.g., \"name,asc\" or \"createdAt,desc\")", + "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": [ + "string", + "null" + ] } }, { - "name": "cover_id", - "in": "path", - "description": "Cover ID", - "required": true, + "name": "full", + "in": "query", + "description": "Return full data including metadata, locks, and related entities.\nDefault is false for backward compatibility.", + "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": "boolean" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SeriesListRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Cover image", + "description": "Paginated list of filtered series (returns FullSeriesListResponse when full=true)", "content": { - "image/jpeg": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse" + } + } } }, - "304": { - "description": "Not modified (client cache is valid)" - }, "403": { "description": "Forbidden" - }, - "404": { - "description": "Series or cover not found" } }, "security": [ @@ -6213,51 +6397,40 @@ ] } }, - "/api/v1/series/{series_id}/covers/{cover_id}/select": { - "put": { + "/api/v1/series/list/alphabetical-groups": { + "post": { "tags": [ "Series" ], - "summary": "Select a cover as the primary cover for a series", - "operationId": "select_series_cover", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" + "summary": "Get alphabetical groups for series", + "description": "Returns a list of alphabetical groups with counts, showing how many series\nstart with each letter/character. This is useful for building A-Z navigation.\nThe same filters as list_series_filtered can be applied.", + "operationId": "list_series_alphabetical_groups", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SeriesListRequest" + } } }, - { - "name": "cover_id", - "in": "path", - "description": "Cover ID to select", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], + "required": true + }, "responses": { "200": { - "description": "Cover selected successfully", + "description": "List of alphabetical groups with counts", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SeriesCoverDto" + "type": "array", + "items": { + "$ref": "#/components/schemas/AlphabeticalGroupDto" + } } } } }, "403": { "description": "Forbidden" - }, - "404": { - "description": "Series or cover not found" } }, "security": [ @@ -6270,43 +6443,51 @@ ] } }, - "/api/v1/series/{series_id}/download": { - "get": { + "/api/v1/series/metadata/auto-match/task/bulk": { + "post": { "tags": [ - "Series" + "Plugin Actions" ], - "summary": "Download all books in a series as a zip file", - "description": "Creates a zip archive containing all detected books in the series.\nOnly includes books that were scanned and detected by the library scanner.", - "operationId": "download_series", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" + "summary": "Enqueue plugin auto-match tasks for multiple series (bulk operation)", + "description": "Creates background tasks to auto-match metadata for multiple series using the specified plugin.\nEach series gets its own task that runs asynchronously in a worker process.", + "operationId": "enqueue_bulk_auto_match_tasks", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnqueueBulkAutoMatchRequest" + } } - } - ], + }, + "required": true + }, "responses": { "200": { - "description": "Zip file containing all books in the series", + "description": "Tasks enqueued", "content": { - "application/zip": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnqueueAutoMatchResponse" + } + } } }, + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Forbidden" + "description": "No permission to edit series" }, "404": { - "description": "Series not found or has no books" + "description": "Plugin not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -6314,41 +6495,64 @@ ] } }, - "/api/v1/series/{series_id}/external-links": { + "/api/v1/series/recently-added": { "get": { "tags": [ "Series" ], - "summary": "Get external links for a series", - "operationId": "get_series_external_links", + "summary": "List recently added series", + "operationId": "list_recently_added_series", "parameters": [ { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, + "name": "limit", + "in": "query", + "description": "Maximum number of series to return (default: 50)", + "required": false, "schema": { - "type": "string", + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "libraryId", + "in": "query", + "description": "Filter by library ID (optional)", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], "format": "uuid" } + }, + { + "name": "full", + "in": "query", + "description": "Return full series data including metadata, locks, genres, tags, etc.", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { "200": { - "description": "List of external links for the series", + "description": "List of recently added series (returns Vec when full=true)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExternalLinkListResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesDto" + } } } } }, "403": { "description": "Forbidden" - }, - "404": { - "description": "Series not found" } }, "security": [ @@ -6359,30 +6563,90 @@ "api_key": [] } ] - }, - "post": { + } + }, + "/api/v1/series/recently-updated": { + "get": { "tags": [ "Series" ], - "summary": "Add or update an external link for a series", - "operationId": "create_external_link", + "summary": "List recently updated series", + "operationId": "list_recently_updated_series", "parameters": [ { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, + "name": "limit", + "in": "query", + "description": "Maximum number of series to return (default: 50)", + "required": false, "schema": { - "type": "string", + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "libraryId", + "in": "query", + "description": "Filter by library ID (optional)", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], "format": "uuid" } + }, + { + "name": "full", + "in": "query", + "description": "Return full series data including metadata, locks, genres, tags, etc.", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "List of recently updated series (returns Vec when full=true)", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesDto" + } + } + } + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] } + ] + } + }, + "/api/v1/series/search": { + "post": { + "tags": [ + "Series" ], + "summary": "Search series by name", + "operationId": "search_series", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateExternalLinkRequest" + "$ref": "#/components/schemas/SearchSeriesRequest" } } }, @@ -6390,20 +6654,20 @@ }, "responses": { "200": { - "description": "External link created or updated", + "description": "Search results (returns Vec when full=true)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExternalLinkDto" + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesDto" + } } } } }, "403": { - "description": "Forbidden - admin only" - }, - "404": { - "description": "Series not found" + "description": "Forbidden" } }, "security": [ @@ -6416,48 +6680,42 @@ ] } }, - "/api/v1/series/{series_id}/external-links/{source}": { - "delete": { + "/api/v1/series/thumbnails/generate": { + "post": { "tags": [ - "Series" + "Thumbnails" ], - "summary": "Delete an external link by source name", - "operationId": "delete_external_link", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" + "summary": "Generate thumbnails for series in a scope", + "description": "This queues a fan-out task that enqueues individual series thumbnail generation tasks.\nSeries thumbnails are the cover images displayed for each series (derived from the first book's cover).\n\n**Scope:**\n- If `library_id` is provided, only series in that library\n- If not provided, all series in all libraries\n\n**Force behavior:**\n- `force: false` (default): Only generates thumbnails for series that don't have one\n- `force: true`: Regenerates all thumbnails, replacing existing ones\n\n# Permission Required\n- `tasks:write`", + "operationId": "generate_series_thumbnails", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateSeriesThumbnailsRequest" + } } }, - { - "name": "source", - "in": "path", - "description": "Source name (e.g., 'myanimelist', 'mangadex')", - "required": true, - "schema": { - "type": "string" - } - } - ], + "required": true + }, "responses": { - "204": { - "description": "External link deleted" + "200": { + "description": "Series thumbnail generation task queued", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTaskResponse" + } + } + } }, "403": { - "description": "Forbidden - admin only" - }, - "404": { - "description": "Series or link not found" + "description": "Permission denied" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -6465,16 +6723,17 @@ ] } }, - "/api/v1/series/{series_id}/external-ratings": { - "get": { + "/api/v1/series/{id}/metadata/apply": { + "post": { "tags": [ - "Series" + "Plugin Actions" ], - "summary": "Get external ratings for a series", - "operationId": "get_series_external_ratings", + "summary": "Apply metadata from a plugin to a series", + "description": "Fetches metadata from a plugin and applies it to the series, respecting\nRBAC permissions and field locks.", + "operationId": "apply_series_metadata", "parameters": [ { - "name": "series_id", + "name": "id", "in": "path", "description": "Series ID", "required": true, @@ -6484,42 +6743,61 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataApplyRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "List of external ratings for the series", + "description": "Metadata applied", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExternalRatingListResponse" + "$ref": "#/components/schemas/MetadataApplyResponse" } } } }, + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Forbidden" + "description": "No permission to edit series" }, "404": { - "description": "Series not found" + "description": "Series or plugin not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - }, + } + }, + "/api/v1/series/{id}/metadata/auto-match": { "post": { "tags": [ - "Series" + "Plugin Actions" ], - "summary": "Add or update an external rating for a series", - "operationId": "create_external_rating", + "summary": "Auto-match and apply metadata from a plugin to a series", + "description": "Searches for the series using the plugin's metadata search, picks the best match,\nand applies the metadata in one step. This is a convenience endpoint for quick\nmetadata updates without user intervention.", + "operationId": "auto_match_series_metadata", "parameters": [ { - "name": "series_id", + "name": "id", "in": "path", "description": "Series ID", "required": true, @@ -6533,7 +6811,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateExternalRatingRequest" + "$ref": "#/components/schemas/MetadataAutoMatchRequest" } } }, @@ -6541,25 +6819,31 @@ }, "responses": { "200": { - "description": "External rating created or updated", + "description": "Auto-match completed", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExternalRatingDto" + "$ref": "#/components/schemas/MetadataAutoMatchResponse" } } } }, + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Forbidden - admin only" + "description": "No permission to edit series" }, "404": { - "description": "Series not found" + "description": "Series or plugin not found or no match found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -6567,16 +6851,17 @@ ] } }, - "/api/v1/series/{series_id}/external-ratings/{source}": { - "delete": { + "/api/v1/series/{id}/metadata/auto-match/task": { + "post": { "tags": [ - "Series" + "Plugin Actions" ], - "summary": "Delete an external rating by source name", - "operationId": "delete_external_rating", + "summary": "Enqueue a plugin auto-match task for a single series", + "description": "Creates a background task to auto-match metadata for a series using the specified plugin.\nThe task runs asynchronously in a worker process and emits a SeriesMetadataUpdated event\nwhen complete.", + "operationId": "enqueue_auto_match_task", "parameters": [ { - "name": "series_id", + "name": "id", "in": "path", "description": "Series ID", "required": true, @@ -6584,31 +6869,45 @@ "type": "string", "format": "uuid" } - }, - { - "name": "source", - "in": "path", - "description": "Source name (e.g., 'myanimelist', 'anilist')", - "required": true, - "schema": { - "type": "string" - } } ], - "responses": { - "204": { - "description": "External rating deleted" - }, - "403": { - "description": "Forbidden - admin only" + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnqueueAutoMatchRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Task enqueued", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnqueueAutoMatchResponse" + } + } + } + }, + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "No permission to edit series" }, "404": { - "description": "Series or rating not found" + "description": "Series or plugin not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -6616,16 +6915,17 @@ ] } }, - "/api/v1/series/{series_id}/genres": { - "get": { + "/api/v1/series/{id}/metadata/preview": { + "post": { "tags": [ - "Genres" + "Plugin Actions" ], - "summary": "Get genres for a series", - "operationId": "get_series_genres", + "summary": "Preview metadata from a plugin for a series", + "description": "Fetches metadata from a plugin and computes a field-by-field diff with the current\nseries metadata, showing which fields will be applied, locked, or denied by RBAC.", + "operationId": "preview_series_metadata", "parameters": [ { - "name": "series_id", + "name": "id", "in": "path", "description": "Series ID", "required": true, @@ -6635,39 +6935,57 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataPreviewRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "List of genres for the series", + "description": "Preview computed", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GenreListResponse" + "$ref": "#/components/schemas/MetadataPreviewResponse" } } } }, + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Forbidden" + "description": "No permission to edit series" }, "404": { - "description": "Series not found" + "description": "Series or plugin not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - }, - "put": { + } + }, + "/api/v1/series/{series_id}": { + "get": { "tags": [ - "Genres" + "Series" ], - "summary": "Set genres for a series (replaces existing)", - "operationId": "set_series_genres", + "summary": "Get series by ID", + "operationId": "get_series", "parameters": [ { "name": "series_id", @@ -6678,32 +6996,28 @@ "type": "string", "format": "uuid" } + }, + { + "name": "full", + "in": "query", + "description": "Return full series data including metadata, locks, genres, tags, etc.", + "required": false, + "schema": { + "type": "boolean" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SetSeriesGenresRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Genres updated", + "description": "Series details (returns FullSeriesResponse when full=true)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GenreListResponse" + "$ref": "#/components/schemas/SeriesDto" } } } }, - "403": { - "description": "Forbidden" - }, "404": { "description": "Series not found" } @@ -6717,12 +7031,13 @@ } ] }, - "post": { + "patch": { "tags": [ - "Genres" + "Series" ], - "summary": "Add a single genre to a series", - "operationId": "add_series_genre", + "summary": "Update series core fields (name/title)", + "description": "Partially updates series_metadata fields. Only provided fields will be updated.\nAbsent fields are unchanged. When name is set to a non-null value, it is automatically locked.", + "operationId": "patch_series", "parameters": [ { "name": "series_id", @@ -6739,7 +7054,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AddSeriesGenreRequest" + "$ref": "#/components/schemas/PatchSeriesRequest" } } }, @@ -6747,11 +7062,11 @@ }, "responses": { "200": { - "description": "Genre added", + "description": "Series updated successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GenreDto" + "$ref": "#/components/schemas/SeriesUpdateResponse" } } } @@ -6773,13 +7088,13 @@ ] } }, - "/api/v1/series/{series_id}/genres/{genre_id}": { - "delete": { + "/api/v1/series/{series_id}/alternate-titles": { + "get": { "tags": [ - "Genres" + "Series" ], - "summary": "Remove a genre from a series", - "operationId": "remove_series_genre", + "summary": "Get alternate titles for a series", + "operationId": "get_series_alternate_titles", "parameters": [ { "name": "series_id", @@ -6790,27 +7105,24 @@ "type": "string", "format": "uuid" } - }, - { - "name": "genre_id", - "in": "path", - "description": "Genre ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } } ], "responses": { - "204": { - "description": "Genre removed from series" + "200": { + "description": "List of alternate titles for the series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlternateTitleListResponse" + } + } + } }, "403": { "description": "Forbidden" }, "404": { - "description": "Series or genre link not found" + "description": "Series not found" } }, "security": [ @@ -6821,16 +7133,13 @@ "api_key": [] } ] - } - }, - "/api/v1/series/{series_id}/metadata": { - "get": { + }, + "post": { "tags": [ "Series" ], - "summary": "Get series metadata including all related data", - "description": "Returns comprehensive metadata with lock states, genres, tags, alternate titles,\nexternal ratings, and external links.", - "operationId": "get_series_metadata", + "summary": "Add an alternate title to a series", + "operationId": "create_alternate_title", "parameters": [ { "name": "series_id", @@ -6843,13 +7152,23 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAlternateTitleRequest" + } + } + }, + "required": true + }, "responses": { - "200": { - "description": "Series metadata with all related data", + "201": { + "description": "Alternate title created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FullSeriesMetadataResponse" + "$ref": "#/components/schemas/AlternateTitleDto" } } } @@ -6869,14 +7188,15 @@ "api_key": [] } ] - }, - "put": { + } + }, + "/api/v1/series/{series_id}/alternate-titles/{title_id}": { + "delete": { "tags": [ "Series" ], - "summary": "Replace all series metadata (PUT)", - "description": "Replaces all metadata fields with the values in the request.\nOmitting a field (or setting it to null) will clear that field.", - "operationId": "replace_series_metadata", + "summary": "Delete an alternate title", + "operationId": "delete_alternate_title", "parameters": [ { "name": "series_id", @@ -6887,34 +7207,27 @@ "type": "string", "format": "uuid" } + }, + { + "name": "title_id", + "in": "path", + "description": "Alternate title ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReplaceSeriesMetadataRequest" - } - } - }, - "required": true - }, "responses": { - "200": { - "description": "Metadata replaced successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SeriesMetadataResponse" - } - } - } + "204": { + "description": "Alternate title deleted" }, "403": { "description": "Forbidden" }, "404": { - "description": "Series not found" + "description": "Series or title not found" } }, "security": [ @@ -6930,9 +7243,8 @@ "tags": [ "Series" ], - "summary": "Partially update series metadata (PATCH)", - "description": "Only provided fields will be updated. Absent fields are unchanged.\nExplicitly null fields will be cleared.", - "operationId": "patch_series_metadata", + "summary": "Update an alternate title", + "operationId": "update_alternate_title", "parameters": [ { "name": "series_id", @@ -6943,13 +7255,23 @@ "type": "string", "format": "uuid" } + }, + { + "name": "title_id", + "in": "path", + "description": "Alternate title ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchSeriesMetadataRequest" + "$ref": "#/components/schemas/UpdateAlternateTitleRequest" } } }, @@ -6957,11 +7279,11 @@ }, "responses": { "200": { - "description": "Metadata updated successfully", + "description": "Alternate title updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SeriesMetadataResponse" + "$ref": "#/components/schemas/AlternateTitleDto" } } } @@ -6970,7 +7292,7 @@ "description": "Forbidden" }, "404": { - "description": "Series not found" + "description": "Series or title not found" } }, "security": [ @@ -6983,13 +7305,14 @@ ] } }, - "/api/v1/series/{series_id}/metadata/locks": { - "get": { + "/api/v1/series/{series_id}/analyze": { + "post": { "tags": [ - "Series" + "Scans" ], - "summary": "Get metadata lock states", - "operationId": "get_metadata_locks", + "summary": "Trigger analysis of all books in a series", + "description": "# Permission Required\n- `series:write`\n\n# Behavior\nEnqueues an AnalyzeSeries task which will create individual AnalyzeBook tasks\nfor each book in the series. All books are analyzed with force=true.\nReturns immediately with a task_id to track progress.", + "operationId": "trigger_series_analysis", "parameters": [ { "name": "series_id", @@ -7004,17 +7327,17 @@ ], "responses": { "200": { - "description": "Current lock states", + "description": "Analysis task enqueued successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MetadataLocks" + "$ref": "#/components/schemas/CreateTaskResponse" } } } }, "403": { - "description": "Forbidden" + "description": "Permission denied" }, "404": { "description": "Series not found" @@ -7022,20 +7345,22 @@ }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - }, - "put": { + } + }, + "/api/v1/series/{series_id}/analyze-unanalyzed": { + "post": { "tags": [ - "Series" + "Scans" ], - "summary": "Update metadata lock states", - "description": "Sets which metadata fields are locked. Locked fields will not be overwritten\nby automatic metadata refresh from book analysis or external sources.", - "operationId": "update_metadata_locks", + "summary": "Trigger analysis of unanalyzed books in a series", + "description": "# Permission Required\n- `series:write`\n\n# Behavior\nEnqueues AnalyzeBook tasks (with force=false) for books in the series that have not been analyzed yet.\nThis is useful for recovering from failures or analyzing newly discovered books.\nReturns immediately with a task_id to track progress.", + "operationId": "trigger_series_unanalyzed_analysis", "parameters": [ { "name": "series_id", @@ -7048,29 +7373,19 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateMetadataLocksRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Lock states updated", + "description": "Analysis tasks enqueued successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MetadataLocks" + "$ref": "#/components/schemas/CreateTaskResponse" } } } }, "403": { - "description": "Forbidden" + "description": "Permission denied" }, "404": { "description": "Series not found" @@ -7078,7 +7393,7 @@ }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -7086,13 +7401,13 @@ ] } }, - "/api/v1/series/{series_id}/purge-deleted": { - "delete": { + "/api/v1/series/{series_id}/books": { + "get": { "tags": [ "Series" ], - "summary": "Purge deleted books from a series", - "operationId": "purge_series_deleted_books", + "summary": "Get books in a series", + "operationId": "get_series_books", "parameters": [ { "name": "series_id", @@ -7103,17 +7418,36 @@ "type": "string", "format": "uuid" } + }, + { + "name": "includeDeleted", + "in": "query", + "description": "Include deleted books in the result", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "name": "full", + "in": "query", + "description": "Return full data including metadata and locks.\nDefault is false for backward compatibility.", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { "200": { - "description": "Number of books purged", + "description": "List of books in the series (returns Vec when full=true)", "content": { - "text/plain": { + "application/json": { "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "array", + "items": { + "$ref": "#/components/schemas/BookDto" + } } } } @@ -7135,14 +7469,13 @@ ] } }, - "/api/v1/series/{series_id}/rating": { - "get": { + "/api/v1/series/{series_id}/cover": { + "post": { "tags": [ - "Ratings" + "Series" ], - "summary": "Get the current user's rating for a series", - "description": "Returns null if no rating exists (not a 404, since the series exists but has no rating)", - "operationId": "get_series_rating", + "summary": "Upload a custom cover/poster for a series", + "operationId": "upload_series_cover", "parameters": [ { "name": "series_id", @@ -7155,24 +7488,24 @@ } } ], - "responses": { - "200": { - "description": "User's rating for the series (null if not rated)", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/UserSeriesRatingDto" - } - ] - } + "requestBody": { + "description": "Multipart form with image file", + "content": { + "multipart/form-data": { + "schema": { + "type": "object" } } }, + "required": true + }, + "responses": { + "200": { + "description": "Cover uploaded successfully" + }, + "400": { + "description": "Invalid image or request" + }, "403": { "description": "Forbidden" }, @@ -7188,13 +7521,15 @@ "api_key": [] } ] - }, - "put": { + } + }, + "/api/v1/series/{series_id}/cover/source": { + "patch": { "tags": [ - "Ratings" + "Series" ], - "summary": "Set (create or update) the current user's rating for a series", - "operationId": "set_series_rating", + "summary": "Set which cover source to use for a series (partial update)", + "operationId": "set_series_cover_source", "parameters": [ { "name": "series_id", @@ -7211,7 +7546,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetUserRatingRequest" + "$ref": "#/components/schemas/SelectCoverSourceRequest" } } }, @@ -7219,17 +7554,7 @@ }, "responses": { "200": { - "description": "Rating saved", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserSeriesRatingDto" - } - } - } - }, - "400": { - "description": "Invalid rating value" + "description": "Cover source updated successfully" }, "403": { "description": "Forbidden" @@ -7246,13 +7571,15 @@ "api_key": [] } ] - }, - "delete": { + } + }, + "/api/v1/series/{series_id}/covers": { + "get": { "tags": [ - "Ratings" + "Series" ], - "summary": "Delete the current user's rating for a series", - "operationId": "delete_series_rating", + "summary": "List all covers for a series", + "operationId": "list_series_covers", "parameters": [ { "name": "series_id", @@ -7266,14 +7593,21 @@ } ], "responses": { - "204": { - "description": "Rating deleted" + "200": { + "description": "List of series covers", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SeriesCoverListResponse" + } + } + } }, "403": { "description": "Forbidden" }, "404": { - "description": "Series or rating not found" + "description": "Series not found" } }, "security": [ @@ -7286,14 +7620,13 @@ ] } }, - "/api/v1/series/{series_id}/ratings/average": { - "get": { + "/api/v1/series/{series_id}/covers/selected": { + "delete": { "tags": [ "Series" ], - "summary": "Get the average community rating for a series", - "description": "Returns the average rating from all users and the total count of ratings.\nRatings are stored on a 0-100 scale internally.", - "operationId": "get_series_average_rating", + "summary": "Reset series cover to default (deselect all custom covers)", + "operationId": "reset_series_cover", "parameters": [ { "name": "series_id", @@ -7307,19 +7640,8 @@ } ], "responses": { - "200": { - "description": "Average rating for the series", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SeriesAverageRatingResponse" - }, - "example": { - "average": 78.5, - "count": 15 - } - } - } + "204": { + "description": "Reset to default cover successfully" }, "403": { "description": "Forbidden" @@ -7338,13 +7660,13 @@ ] } }, - "/api/v1/series/{series_id}/read": { - "post": { + "/api/v1/series/{series_id}/covers/{cover_id}": { + "delete": { "tags": [ "Series" ], - "summary": "Mark all books in a series as read", - "operationId": "mark_series_as_read", + "summary": "Delete a cover from a series", + "operationId": "delete_series_cover", "parameters": [ { "name": "series_id", @@ -7355,24 +7677,30 @@ "type": "string", "format": "uuid" } + }, + { + "name": "cover_id", + "in": "path", + "description": "Cover ID to delete", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } } ], "responses": { - "200": { - "description": "Series marked as read", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MarkReadResponse" - } - } - } + "204": { + "description": "Cover deleted successfully" + }, + "400": { + "description": "Cannot delete the only selected cover" }, "403": { "description": "Forbidden" }, "404": { - "description": "Series not found" + "description": "Series or cover not found" } }, "security": [ @@ -7385,13 +7713,14 @@ ] } }, - "/api/v1/series/{series_id}/sharing-tags": { + "/api/v1/series/{series_id}/covers/{cover_id}/image": { "get": { "tags": [ - "Sharing Tags" + "Series" ], - "summary": "Get sharing tags for a series (admin only)", - "operationId": "get_series_sharing_tags", + "summary": "Get a specific cover image for a series", + "description": "Supports HTTP conditional caching with ETag and Last-Modified headers,\nreturning 304 Not Modified when the client has a valid cached copy.", + "operationId": "get_series_cover_image", "parameters": [ { "name": "series_id", @@ -7402,24 +7731,33 @@ "type": "string", "format": "uuid" } + }, + { + "name": "cover_id", + "in": "path", + "description": "Cover ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } } ], "responses": { "200": { - "description": "List of sharing tags for the series", + "description": "Cover image", "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SharingTagSummaryDto" - } - } - } + "image/jpeg": {} } }, + "304": { + "description": "Not modified (client cache is valid)" + }, "403": { - "description": "Forbidden - Missing permission" + "description": "Forbidden" + }, + "404": { + "description": "Series or cover not found" } }, "security": [ @@ -7430,13 +7768,15 @@ "api_key": [] } ] - }, + } + }, + "/api/v1/series/{series_id}/covers/{cover_id}/select": { "put": { "tags": [ - "Sharing Tags" + "Series" ], - "summary": "Set sharing tags for a series (replaces existing) (admin only)", - "operationId": "set_series_sharing_tags", + "summary": "Select a cover as the primary cover for a series", + "operationId": "select_series_cover", "parameters": [ { "name": "series_id", @@ -7447,56 +7787,11 @@ "type": "string", "format": "uuid" } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SetSeriesSharingTagsRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Sharing tags set", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SharingTagSummaryDto" - } - } - } - } - }, - "403": { - "description": "Forbidden - Missing permission" - } - }, - "security": [ - { - "jwt_bearer": [] }, { - "api_key": [] - } - ] - }, - "post": { - "tags": [ - "Sharing Tags" - ], - "summary": "Add a sharing tag to a series (admin only)", - "operationId": "add_series_sharing_tag", - "parameters": [ - { - "name": "series_id", + "name": "cover_id", "in": "path", - "description": "Series ID", + "description": "Cover ID to select", "required": true, "schema": { "type": "string", @@ -7504,75 +7799,22 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ModifySeriesSharingTagRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Sharing tag added" - }, - "400": { - "description": "Tag already assigned" - }, - "403": { - "description": "Forbidden - Missing permission" - } - }, - "security": [ - { - "jwt_bearer": [] - }, - { - "api_key": [] - } - ] - } - }, - "/api/v1/series/{series_id}/sharing-tags/{tag_id}": { - "delete": { - "tags": [ - "Sharing Tags" - ], - "summary": "Remove a sharing tag from a series (admin only)", - "operationId": "remove_series_sharing_tag", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "tag_id", - "in": "path", - "description": "Sharing tag ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" + "description": "Cover selected successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SeriesCoverDto" + } + } } - } - ], - "responses": { - "204": { - "description": "Sharing tag removed" }, "403": { - "description": "Forbidden - Missing permission" + "description": "Forbidden" }, "404": { - "description": "Sharing tag not assigned to series" + "description": "Series or cover not found" } }, "security": [ @@ -7585,13 +7827,14 @@ ] } }, - "/api/v1/series/{series_id}/tags": { + "/api/v1/series/{series_id}/download": { "get": { "tags": [ - "Tags" + "Series" ], - "summary": "Get tags for a series", - "operationId": "get_series_tags", + "summary": "Download all books in a series as a zip file", + "description": "Creates a zip archive containing all detected books in the series.\nOnly includes books that were scanned and detected by the library scanner.", + "operationId": "download_series", "parameters": [ { "name": "series_id", @@ -7606,20 +7849,16 @@ ], "responses": { "200": { - "description": "List of tags for the series", + "description": "Zip file containing all books in the series", "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TagListResponse" - } - } + "application/zip": {} } }, "403": { "description": "Forbidden" }, "404": { - "description": "Series not found" + "description": "Series not found or has no books" } }, "security": [ @@ -7630,13 +7869,15 @@ "api_key": [] } ] - }, - "put": { + } + }, + "/api/v1/series/{series_id}/external-links": { + "get": { "tags": [ - "Tags" + "Series" ], - "summary": "Set tags for a series (replaces existing)", - "operationId": "set_series_tags", + "summary": "Get external links for a series", + "operationId": "get_series_external_links", "parameters": [ { "name": "series_id", @@ -7649,23 +7890,13 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SetSeriesTagsRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Tags updated", + "description": "List of external links for the series", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TagListResponse" + "$ref": "#/components/schemas/ExternalLinkListResponse" } } } @@ -7688,10 +7919,10 @@ }, "post": { "tags": [ - "Tags" + "Series" ], - "summary": "Add a single tag to a series", - "operationId": "add_series_tag", + "summary": "Add or update an external link for a series", + "operationId": "create_external_link", "parameters": [ { "name": "series_id", @@ -7708,7 +7939,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AddSeriesTagRequest" + "$ref": "#/components/schemas/CreateExternalLinkRequest" } } }, @@ -7716,17 +7947,17 @@ }, "responses": { "200": { - "description": "Tag added", + "description": "External link created or updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TagDto" + "$ref": "#/components/schemas/ExternalLinkDto" } } } }, "403": { - "description": "Forbidden" + "description": "Forbidden - admin only" }, "404": { "description": "Series not found" @@ -7742,13 +7973,13 @@ ] } }, - "/api/v1/series/{series_id}/tags/{tag_id}": { + "/api/v1/series/{series_id}/external-links/{source}": { "delete": { "tags": [ - "Tags" + "Series" ], - "summary": "Remove a tag from a series", - "operationId": "remove_series_tag", + "summary": "Delete an external link by source name", + "operationId": "delete_external_link", "parameters": [ { "name": "series_id", @@ -7761,25 +7992,24 @@ } }, { - "name": "tag_id", + "name": "source", "in": "path", - "description": "Tag ID", + "description": "Source name (e.g., 'myanimelist', 'mangadex')", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], "responses": { "204": { - "description": "Tag removed from series" + "description": "External link deleted" }, "403": { - "description": "Forbidden" + "description": "Forbidden - admin only" }, "404": { - "description": "Series or tag link not found" + "description": "Series or link not found" } }, "security": [ @@ -7792,13 +8022,13 @@ ] } }, - "/api/v1/series/{series_id}/thumbnail": { + "/api/v1/series/{series_id}/external-ratings": { "get": { "tags": [ "Series" ], - "summary": "Get thumbnail/cover image for a series", - "operationId": "get_series_thumbnail", + "summary": "Get external ratings for a series", + "operationId": "get_series_external_ratings", "parameters": [ { "name": "series_id", @@ -7813,14 +8043,15 @@ ], "responses": { "200": { - "description": "Thumbnail image", + "description": "List of external ratings for the series", "content": { - "image/jpeg": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalRatingListResponse" + } + } } }, - "304": { - "description": "Not modified (client cache is valid)" - }, "403": { "description": "Forbidden" }, @@ -7836,16 +8067,13 @@ "api_key": [] } ] - } - }, - "/api/v1/series/{series_id}/thumbnails/generate": { + }, "post": { "tags": [ - "Thumbnails" + "Series" ], - "summary": "Generate thumbnails for all books in a series", - "description": "Queues a fan-out task that enqueues individual thumbnail generation tasks for each book in the series.\n\n# Permission Required\n- `tasks:write`", - "operationId": "generate_series_thumbnails", + "summary": "Add or update an external rating for a series", + "operationId": "create_external_rating", "parameters": [ { "name": "series_id", @@ -7862,7 +8090,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ForceRequest" + "$ref": "#/components/schemas/CreateExternalRatingRequest" } } }, @@ -7870,17 +8098,17 @@ }, "responses": { "200": { - "description": "Thumbnail generation task queued", + "description": "External rating created or updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTaskResponse" + "$ref": "#/components/schemas/ExternalRatingDto" } } } }, "403": { - "description": "Permission denied" + "description": "Forbidden - admin only" }, "404": { "description": "Series not found" @@ -7888,7 +8116,7 @@ }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -7896,13 +8124,13 @@ ] } }, - "/api/v1/series/{series_id}/unread": { - "post": { + "/api/v1/series/{series_id}/external-ratings/{source}": { + "delete": { "tags": [ "Series" ], - "summary": "Mark all books in a series as unread", - "operationId": "mark_series_as_unread", + "summary": "Delete an external rating by source name", + "operationId": "delete_external_rating", "parameters": [ { "name": "series_id", @@ -7913,24 +8141,26 @@ "type": "string", "format": "uuid" } + }, + { + "name": "source", + "in": "path", + "description": "Source name (e.g., 'myanimelist', 'anilist')", + "required": true, + "schema": { + "type": "string" + } } ], "responses": { - "200": { - "description": "Series marked as unread", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MarkReadResponse" - } - } - } + "204": { + "description": "External rating deleted" }, "403": { - "description": "Forbidden" + "description": "Forbidden - admin only" }, "404": { - "description": "Series not found" + "description": "Series or rating not found" } }, "security": [ @@ -7943,64 +8173,41 @@ ] } }, - "/api/v1/settings/branding": { + "/api/v1/series/{series_id}/genres": { "get": { "tags": [ - "Settings" + "Genres" ], - "summary": "Get branding settings (unauthenticated)", - "description": "Returns branding-related settings that are needed on unauthenticated pages\nlike the login screen. This endpoint does not require authentication.", - "operationId": "get_branding_settings", - "responses": { - "200": { - "description": "Branding settings", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BrandingSettingsDto" - }, - "example": { - "application_name": "Codex" - } - } + "summary": "Get genres for a series", + "operationId": "get_series_genres", + "parameters": [ + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" } } - } - } - }, - "/api/v1/settings/public": { - "get": { - "tags": [ - "Settings" ], - "summary": "Get public display settings (authenticated users)", - "description": "Returns non-sensitive settings that affect UI/display behavior.\nThis endpoint is available to all authenticated users, not just admins.", - "operationId": "get_public_settings", "responses": { "200": { - "description": "Public settings", + "description": "List of genres for the series", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/PublicSettingDto" - }, - "propertyNames": { - "type": "string" - } - }, - "example": { - "display.custom_metadata_template": { - "key": "display.custom_metadata_template", - "value": "{{#if custom_metadata}}## Additional Information\n{{#each custom_metadata}}- **{{@key}}**: {{this}}\n{{/each}}{{/if}}" - } + "$ref": "#/components/schemas/GenreListResponse" } } } }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Series not found" } }, "security": [ @@ -8011,21 +8218,30 @@ "api_key": [] } ] - } - }, - "/api/v1/setup/initialize": { - "post": { + }, + "put": { "tags": [ - "Setup" + "Genres" + ], + "summary": "Set genres for a series (replaces existing)", + "operationId": "set_series_genres", + "parameters": [ + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "summary": "Initialize application setup by creating the first admin user", - "description": "Creates the first admin user with email verification bypassed and returns a JWT token", - "operationId": "initialize_setup", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InitializeSetupRequest" + "$ref": "#/components/schemas/SetSeriesGenresRequest" } } }, @@ -8033,37 +8249,54 @@ }, "responses": { "200": { - "description": "Setup initialized", + "description": "Genres updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InitializeSetupResponse" + "$ref": "#/components/schemas/GenreListResponse" } } } }, - "400": { - "description": "Invalid request or setup already completed" + "403": { + "description": "Forbidden" }, - "422": { - "description": "Validation error" + "404": { + "description": "Series not found" } - } - } - }, - "/api/v1/setup/settings": { - "patch": { + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + }, + "post": { "tags": [ - "Setup" + "Genres" + ], + "summary": "Add a single genre to a series", + "operationId": "add_series_genre", + "parameters": [ + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "summary": "Configure initial settings (optional step in setup wizard)", - "description": "Allows the newly created admin to configure database settings", - "operationId": "configure_initial_settings", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ConfigureSettingsRequest" + "$ref": "#/components/schemas/AddSeriesGenreRequest" } } }, @@ -8071,92 +8304,70 @@ }, "responses": { "200": { - "description": "Settings configured", + "description": "Genre added", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ConfigureSettingsResponse" + "$ref": "#/components/schemas/GenreDto" } } } }, "403": { - "description": "Forbidden - Admin only" + "description": "Forbidden" + }, + "404": { + "description": "Series not found" } }, "security": [ { "jwt_bearer": [] + }, + { + "api_key": [] } ] } }, - "/api/v1/setup/status": { - "get": { - "tags": [ - "Setup" - ], - "summary": "Check if initial setup is required", - "description": "Returns whether the application needs initial setup (no users exist)", - "operationId": "setup_status", - "responses": { - "200": { - "description": "Setup status", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SetupStatusResponse" - } - } - } - } - } - } - }, - "/api/v1/tags": { - "get": { + "/api/v1/series/{series_id}/genres/{genre_id}": { + "delete": { "tags": [ - "Tags" + "Genres" ], - "summary": "List all tags", - "operationId": "list_tags", + "summary": "Remove a genre from a series", + "operationId": "remove_series_genre", "parameters": [ { - "name": "page", - "in": "query", - "description": "Page number (1-indexed, default 1)", - "required": false, + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string", + "format": "uuid" } }, { - "name": "pageSize", - "in": "query", - "description": "Number of items per page (default 50, max 500)", - "required": false, + "name": "genre_id", + "in": "path", + "description": "Genre ID", + "required": true, "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string", + "format": "uuid" } } ], "responses": { - "200": { - "description": "List of all tags", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaginatedResponse_TagDto" - } - } - } + "204": { + "description": "Genre removed from series" }, "403": { "description": "Forbidden" + }, + "404": { + "description": "Series or genre link not found" } }, "security": [ @@ -8169,50 +8380,19 @@ ] } }, - "/api/v1/tags/cleanup": { - "post": { + "/api/v1/series/{series_id}/metadata": { + "get": { "tags": [ - "Tags" + "Series" ], - "summary": "Delete all unused tags (tags with no series linked)", - "operationId": "cleanup_tags", - "responses": { - "200": { - "description": "Cleanup completed", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TaxonomyCleanupResponse" - } - } - } - }, - "403": { - "description": "Forbidden - admin only" - } - }, - "security": [ - { - "jwt_bearer": [] - }, - { - "api_key": [] - } - ] - } - }, - "/api/v1/tags/{tag_id}": { - "delete": { - "tags": [ - "Tags" - ], - "summary": "Delete a tag from the taxonomy (admin only)", - "operationId": "delete_tag", + "summary": "Get series metadata including all related data", + "description": "Returns comprehensive metadata with lock states, genres, tags, alternate titles,\nexternal ratings, and external links.", + "operationId": "get_series_metadata", "parameters": [ { - "name": "tag_id", + "name": "series_id", "in": "path", - "description": "Tag ID", + "description": "Series ID", "required": true, "schema": { "type": "string", @@ -8221,14 +8401,21 @@ } ], "responses": { - "204": { - "description": "Tag deleted" + "200": { + "description": "Series metadata with all related data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FullSeriesMetadataResponse" + } + } + } }, "403": { - "description": "Forbidden - admin only" + "description": "Forbidden" }, "404": { - "description": "Tag not found" + "description": "Series not found" } }, "security": [ @@ -8239,89 +8426,87 @@ "api_key": [] } ] - } - }, - "/api/v1/tasks": { - "get": { + }, + "put": { "tags": [ - "Task Queue" + "Series" ], - "summary": "List tasks with optional filtering", - "description": "# Permission Required\n- `tasks:read`", - "operationId": "list_tasks", + "summary": "Replace all series metadata (PUT)", + "description": "Replaces all metadata fields with the values in the request.\nOmitting a field (or setting it to null) will clear that field.", + "operationId": "replace_series_metadata", "parameters": [ { - "name": "status", - "in": "query", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "taskType", - "in": "query", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "limit", - "in": "query", - "required": false, + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string", + "format": "uuid" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceSeriesMetadataRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Tasks retrieved successfully", + "description": "Metadata replaced successfully", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TaskResponse" - } + "$ref": "#/components/schemas/SeriesMetadataResponse" } } } }, "403": { - "description": "Permission denied" + "description": "Forbidden" + }, + "404": { + "description": "Series not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] } ] }, - "post": { + "patch": { "tags": [ - "Task Queue" + "Series" + ], + "summary": "Partially update series metadata (PATCH)", + "description": "Only provided fields will be updated. Absent fields are unchanged.\nExplicitly null fields will be cleared.", + "operationId": "patch_series_metadata", + "parameters": [ + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "summary": "Create a new task", - "description": "# Permission Required\n- `tasks:write`", - "operationId": "create_task", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTaskRequest" + "$ref": "#/components/schemas/PatchSeriesMetadataRequest" } } }, @@ -8329,25 +8514,25 @@ }, "responses": { "200": { - "description": "Task created successfully", + "description": "Metadata updated successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTaskResponse" + "$ref": "#/components/schemas/SeriesMetadataResponse" } } } }, - "400": { - "description": "Invalid request" - }, "403": { - "description": "Permission denied" + "description": "Forbidden" + }, + "404": { + "description": "Series not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -8355,77 +8540,102 @@ ] } }, - "/api/v1/tasks/nuke": { - "delete": { + "/api/v1/series/{series_id}/metadata/locks": { + "get": { "tags": [ - "Task Queue" + "Series" + ], + "summary": "Get metadata lock states", + "operationId": "get_metadata_locks", + "parameters": [ + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "summary": "Nuclear option: Delete ALL tasks", - "description": "# Permission Required\n- `admin`", - "operationId": "nuke_all_tasks", "responses": { "200": { - "description": "All tasks deleted", + "description": "Current lock states", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PurgeTasksResponse" + "$ref": "#/components/schemas/MetadataLocks" } } } }, "403": { - "description": "Permission denied (admin only)" + "description": "Forbidden" + }, + "404": { + "description": "Series not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] } ] - } - }, - "/api/v1/tasks/purge": { - "delete": { + }, + "put": { "tags": [ - "Task Queue" + "Series" ], - "summary": "Purge old completed/failed tasks", - "description": "# Permission Required\n- `tasks:write`", - "operationId": "purge_old_tasks", + "summary": "Update metadata lock states", + "description": "Sets which metadata fields are locked. Locked fields will not be overwritten\nby automatic metadata refresh from book analysis or external sources.", + "operationId": "update_metadata_locks", "parameters": [ { - "name": "days", - "in": "query", - "description": "Delete tasks older than N days (default: 30)", - "required": false, + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, "schema": { - "type": "integer", - "format": "int64" + "type": "string", + "format": "uuid" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateMetadataLocksRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Tasks purged successfully", + "description": "Lock states updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PurgeTasksResponse" + "$ref": "#/components/schemas/MetadataLocks" } } } }, "403": { - "description": "Permission denied" + "description": "Forbidden" + }, + "404": { + "description": "Series not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -8433,59 +8643,43 @@ ] } }, - "/api/v1/tasks/stats": { - "get": { + "/api/v1/series/{series_id}/purge-deleted": { + "delete": { "tags": [ - "Task Queue" + "Series" ], - "summary": "Get queue statistics", - "description": "# Permission Required\n- `tasks:read`", - "operationId": "get_task_stats", - "responses": { - "200": { - "description": "Statistics retrieved successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TaskStats" - } - } - } - }, - "403": { - "description": "Permission denied" - } - }, - "security": [ - { - "bearer_auth": [] - }, + "summary": "Purge deleted books from a series", + "operationId": "purge_series_deleted_books", + "parameters": [ { - "api_key": [] + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } } - ] - } - }, - "/api/v1/tasks/stream": { - "get": { - "tags": [ - "Events" ], - "summary": "Subscribe to real-time task progress events via SSE", - "description": "Clients can subscribe to this endpoint to receive real-time notifications\nabout background task progress (analyze_book, generate_thumbnails, etc.).\n\n## Authentication\nRequires valid authentication with `LibrariesRead` permission.\n\n## Event Format\nEvents are sent as JSON-encoded `TaskProgressEvent` objects with the following structure:\n```json\n{\n \"task_id\": \"uuid\",\n \"task_type\": \"analyze_book\",\n \"status\": \"running\",\n \"progress\": {\n \"current\": 5,\n \"total\": 10,\n \"message\": \"Processing book 5 of 10\"\n },\n \"started_at\": \"2024-01-06T12:00:00Z\",\n \"library_id\": \"uuid\"\n}\n```\n\n## Keep-Alive\nA keep-alive message is sent every 15 seconds to prevent connection timeout.", - "operationId": "task_progress_stream", "responses": { "200": { - "description": "SSE stream of task progress events", + "description": "Number of books purged", "content": { - "text/event-stream": {} + "text/plain": { + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } } }, - "401": { - "description": "Unauthorized" - }, "403": { "description": "Forbidden" + }, + "404": { + "description": "Series not found" } }, "security": [ @@ -8498,19 +8692,19 @@ ] } }, - "/api/v1/tasks/{task_id}": { + "/api/v1/series/{series_id}/rating": { "get": { "tags": [ - "Task Queue" + "Ratings" ], - "summary": "Get task by ID", - "description": "# Permission Required\n- `tasks:read`", - "operationId": "get_task", + "summary": "Get the current user's rating for a series", + "description": "Returns null if no rating exists (not a 404, since the series exists but has no rating)", + "operationId": "get_series_rating", "parameters": [ { - "name": "task_id", + "name": "series_id", "in": "path", - "description": "Task ID", + "description": "Series ID", "required": true, "schema": { "type": "string", @@ -8520,45 +8714,49 @@ ], "responses": { "200": { - "description": "Task retrieved successfully", + "description": "User's rating for the series (null if not rated)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TaskResponse" + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/UserSeriesRatingDto" + } + ] } } } }, "403": { - "description": "Permission denied" + "description": "Forbidden" }, "404": { - "description": "Task not found" + "description": "Series not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] } ] - } - }, - "/api/v1/tasks/{task_id}/cancel": { - "post": { + }, + "put": { "tags": [ - "Task Queue" + "Ratings" ], - "summary": "Cancel a task", - "description": "# Permission Required\n- `tasks:write`", - "operationId": "cancel_task", + "summary": "Set (create or update) the current user's rating for a series", + "operationId": "set_series_rating", "parameters": [ { - "name": "task_id", + "name": "series_id", "in": "path", - "description": "Task ID", + "description": "Series ID", "required": true, "schema": { "type": "string", @@ -8566,50 +8764,57 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetUserRatingRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Task cancelled successfully", + "description": "Rating saved", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/UserSeriesRatingDto" } } } }, "400": { - "description": "Task cannot be cancelled" + "description": "Invalid rating value" }, "403": { - "description": "Permission denied" + "description": "Forbidden" }, "404": { - "description": "Task not found" + "description": "Series not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] } ] - } - }, - "/api/v1/tasks/{task_id}/retry": { - "post": { + }, + "delete": { "tags": [ - "Task Queue" + "Ratings" ], - "summary": "Retry a failed task", - "description": "# Permission Required\n- `tasks:write`", - "operationId": "retry_task", + "summary": "Delete the current user's rating for a series", + "operationId": "delete_series_rating", "parameters": [ { - "name": "task_id", + "name": "series_id", "in": "path", - "description": "Task ID", + "description": "Series ID", "required": true, "schema": { "type": "string", @@ -8618,29 +8823,19 @@ } ], "responses": { - "200": { - "description": "Task queued for retry", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MessageResponse" - } - } - } - }, - "400": { - "description": "Task is not in failed state" + "204": { + "description": "Rating deleted" }, "403": { - "description": "Permission denied" + "description": "Forbidden" }, "404": { - "description": "Task not found" + "description": "Series or rating not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -8648,19 +8843,19 @@ ] } }, - "/api/v1/tasks/{task_id}/unlock": { - "post": { + "/api/v1/series/{series_id}/ratings/average": { + "get": { "tags": [ - "Task Queue" + "Series" ], - "summary": "Unlock a stuck task", - "description": "# Permission Required\n- `tasks:write`", - "operationId": "unlock_task", + "summary": "Get the average community rating for a series", + "description": "Returns the average rating from all users and the total count of ratings.\nRatings are stored on a 0-100 scale internally.", + "operationId": "get_series_average_rating", "parameters": [ { - "name": "task_id", + "name": "series_id", "in": "path", - "description": "Task ID", + "description": "Series ID", "required": true, "schema": { "type": "string", @@ -8670,25 +8865,29 @@ ], "responses": { "200": { - "description": "Task unlocked successfully", + "description": "Average rating for the series", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/SeriesAverageRatingResponse" + }, + "example": { + "average": 78.5, + "count": 15 } } } }, "403": { - "description": "Permission denied" + "description": "Forbidden" }, "404": { - "description": "Task not found" + "description": "Series not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -8696,42 +8895,46 @@ ] } }, - "/api/v1/thumbnails/generate": { + "/api/v1/series/{series_id}/read": { "post": { "tags": [ - "Thumbnails" + "Series" ], - "summary": "Generate thumbnails for books in a scope", - "description": "This queues a fan-out task that enqueues individual thumbnail generation tasks for each book.\n\n**Scope priority:**\n1. If `series_id` is provided, only books in that series\n2. If `library_id` is provided, only books in that library\n3. If neither is provided, all books in all libraries\n\n**Force behavior:**\n- `force: false` (default): Only generates thumbnails for books that don't have one\n- `force: true`: Regenerates all thumbnails, replacing existing ones\n\n# Permission Required\n- `tasks:write`", - "operationId": "generate_thumbnails", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GenerateThumbnailsRequest" - } + "summary": "Mark all books in a series as read", + "operationId": "mark_series_as_read", + "parameters": [ + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" } - }, - "required": true - }, + } + ], "responses": { "200": { - "description": "Thumbnail generation task queued", + "description": "Series marked as read", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTaskResponse" + "$ref": "#/components/schemas/MarkReadResponse" } } } }, "403": { - "description": "Permission denied" + "description": "Forbidden" + }, + "404": { + "description": "Series not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -8739,31 +8942,46 @@ ] } }, - "/api/v1/user/preferences": { + "/api/v1/series/{series_id}/sharing-tags": { "get": { "tags": [ - "User Preferences" + "Sharing Tags" + ], + "summary": "Get sharing tags for a series (admin only)", + "operationId": "get_series_sharing_tags", + "parameters": [ + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "summary": "Get all preferences for the authenticated user", - "operationId": "get_all_preferences", "responses": { "200": { - "description": "User preferences retrieved", + "description": "List of sharing tags for the series", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserPreferencesResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/SharingTagSummaryDto" + } } } } }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden - Missing permission" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -8772,15 +8990,27 @@ }, "put": { "tags": [ - "User Preferences" + "Sharing Tags" + ], + "summary": "Set sharing tags for a series (replaces existing) (admin only)", + "operationId": "set_series_sharing_tags", + "parameters": [ + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "summary": "Set multiple preferences at once", - "operationId": "set_bulk_preferences", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BulkSetPreferencesRequest" + "$ref": "#/components/schemas/SetSeriesSharingTagsRequest" } } }, @@ -8788,25 +9018,73 @@ }, "responses": { "200": { - "description": "Preferences updated successfully", + "description": "Sharing tags set", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetPreferencesResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/SharingTagSummaryDto" + } } } } }, + "403": { + "description": "Forbidden - Missing permission" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + }, + "post": { + "tags": [ + "Sharing Tags" + ], + "summary": "Add a sharing tag to a series (admin only)", + "operationId": "add_series_sharing_tag", + "parameters": [ + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModifySeriesSharingTagRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Sharing tag added" + }, "400": { - "description": "Invalid preference key or value" + "description": "Tag already assigned" }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden - Missing permission" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -8814,45 +9092,96 @@ ] } }, - "/api/v1/user/preferences/{key}": { + "/api/v1/series/{series_id}/sharing-tags/{tag_id}": { + "delete": { + "tags": [ + "Sharing Tags" + ], + "summary": "Remove a sharing tag from a series (admin only)", + "operationId": "remove_series_sharing_tag", + "parameters": [ + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "tag_id", + "in": "path", + "description": "Sharing tag ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Sharing tag removed" + }, + "403": { + "description": "Forbidden - Missing permission" + }, + "404": { + "description": "Sharing tag not assigned to series" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/series/{series_id}/tags": { "get": { "tags": [ - "User Preferences" + "Tags" ], - "summary": "Get a single preference by key", - "operationId": "get_preference", + "summary": "Get tags for a series", + "operationId": "get_series_tags", "parameters": [ { - "name": "key", + "name": "series_id", "in": "path", - "description": "Preference key (e.g., 'ui.theme')", + "description": "Series ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], "responses": { "200": { - "description": "Preference retrieved", + "description": "List of tags for the series", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserPreferenceDto" + "$ref": "#/components/schemas/TagListResponse" } } } }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden" }, "404": { - "description": "Preference not found" + "description": "Series not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -8861,18 +9190,19 @@ }, "put": { "tags": [ - "User Preferences" + "Tags" ], - "summary": "Set a single preference value", - "operationId": "set_preference", + "summary": "Set tags for a series (replaces existing)", + "operationId": "set_series_tags", "parameters": [ { - "name": "key", + "name": "series_id", "in": "path", - "description": "Preference key (e.g., 'ui.theme')", + "description": "Series ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -8880,7 +9210,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetPreferenceRequest" + "$ref": "#/components/schemas/SetSeriesTagsRequest" } } }, @@ -8888,93 +9218,75 @@ }, "responses": { "200": { - "description": "Preference set successfully", + "description": "Tags updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserPreferenceDto" + "$ref": "#/components/schemas/TagListResponse" } } } }, - "400": { - "description": "Invalid preference value" + "403": { + "description": "Forbidden" }, - "401": { - "description": "Unauthorized" + "404": { + "description": "Series not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] } ] }, - "delete": { + "post": { "tags": [ - "User Preferences" + "Tags" ], - "summary": "Delete (reset) a preference to its default", - "operationId": "delete_preference", + "summary": "Add a single tag to a series", + "operationId": "add_series_tag", "parameters": [ { - "name": "key", + "name": "series_id", "in": "path", - "description": "Preference key to delete", + "description": "Series ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], - "responses": { - "200": { - "description": "Preference deleted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeletePreferenceResponse" - } + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddSeriesTagRequest" } } }, - "401": { - "description": "Unauthorized" - } + "required": true }, - "security": [ - { - "bearer_auth": [] - }, - { - "api_key": [] - } - ] - } - }, - "/api/v1/user/ratings": { - "get": { - "tags": [ - "Ratings" - ], - "summary": "List all of the current user's ratings", - "operationId": "list_user_ratings", "responses": { "200": { - "description": "List of user's ratings", + "description": "Tag added", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserRatingsListResponse" + "$ref": "#/components/schemas/TagDto" } } } }, "403": { "description": "Forbidden" + }, + "404": { + "description": "Series not found" } }, "security": [ @@ -8987,162 +9299,44 @@ ] } }, - "/api/v1/user/sharing-tags": { - "get": { + "/api/v1/series/{series_id}/tags/{tag_id}": { + "delete": { "tags": [ - "Sharing Tags" + "Tags" ], - "summary": "Get current user's sharing tag grants", - "operationId": "get_my_sharing_tags", - "responses": { - "200": { - "description": "List of sharing tag grants for the current user", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserSharingTagGrantsResponse" - } - } - } - } - }, - "security": [ - { - "jwt_bearer": [] - }, - { - "api_key": [] - } - ] - } - }, - "/api/v1/users": { - "get": { - "tags": [ - "Users" - ], - "summary": "List all users (admin only) with pagination and filtering", - "operationId": "list_users", + "summary": "Remove a tag from a series", + "operationId": "remove_series_tag", "parameters": [ { - "name": "role", - "in": "query", - "description": "Filter by role", - "required": false, - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/UserRole" - } - ] - } - }, - { - "name": "sharingTag", - "in": "query", - "description": "Filter by sharing tag name (users who have a grant for this tag)", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "sharingTagMode", - "in": "query", - "description": "Filter by sharing tag access mode (allow/deny) - only used with sharing_tag", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "page", - "in": "query", - "description": "Page number (1-indexed, default 1)", - "required": false, + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string", + "format": "uuid" } }, { - "name": "pageSize", - "in": "query", - "description": "Number of items per page (max 100, default 50)", - "required": false, + "name": "tag_id", + "in": "path", + "description": "Tag ID", + "required": true, "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string", + "format": "uuid" } } ], "responses": { - "200": { - "description": "Paginated list of users", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaginatedResponse_UserDto" - } - } - } + "204": { + "description": "Tag removed from series" }, "403": { - "description": "Forbidden - Admin only" - } - }, - "security": [ - { - "jwt_bearer": [] - }, - { - "api_key": [] - } - ] - }, - "post": { - "tags": [ - "Users" - ], - "summary": "Create a new user (admin only)", - "operationId": "create_user", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateUserRequest" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "User created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserDto" - } - } - } - }, - "400": { - "description": "Invalid request" + "description": "Forbidden" }, - "403": { - "description": "Forbidden - Admin only" + "404": { + "description": "Series or tag link not found" } }, "security": [ @@ -9155,18 +9349,18 @@ ] } }, - "/api/v1/users/{user_id}": { + "/api/v1/series/{series_id}/thumbnail": { "get": { "tags": [ - "Users" + "Series" ], - "summary": "Get user by ID (admin only)", - "operationId": "get_user", + "summary": "Get thumbnail/cover image for a series", + "operationId": "get_series_thumbnail", "parameters": [ { - "name": "user_id", + "name": "series_id", "in": "path", - "description": "User ID", + "description": "Series ID", "required": true, "schema": { "type": "string", @@ -9176,58 +9370,19 @@ ], "responses": { "200": { - "description": "User details with sharing tags", + "description": "Thumbnail image", "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserDetailDto" - } - } + "image/jpeg": {} } }, - "403": { - "description": "Forbidden - Admin only" - }, - "404": { - "description": "User not found" - } - }, - "security": [ - { - "jwt_bearer": [] - }, - { - "api_key": [] - } - ] - }, - "delete": { - "tags": [ - "Users" - ], - "summary": "Delete a user (admin only)", - "operationId": "delete_user", - "parameters": [ - { - "name": "user_id", - "in": "path", - "description": "User ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "204": { - "description": "User deleted" + "304": { + "description": "Not modified (client cache is valid)" }, "403": { - "description": "Forbidden - Admin only" + "description": "Forbidden" }, "404": { - "description": "User not found" + "description": "Series not found" } }, "security": [ @@ -9238,18 +9393,21 @@ "api_key": [] } ] - }, - "patch": { + } + }, + "/api/v1/series/{series_id}/thumbnail/generate": { + "post": { "tags": [ - "Users" + "Thumbnails" ], - "summary": "Update a user (admin only, partial update)", - "operationId": "update_user", + "summary": "Generate thumbnail for a series", + "description": "Queues a task to generate (or regenerate) the thumbnail for a specific series.\nThe series thumbnail is derived from the first book's cover.\n\n# Permission Required\n- `tasks:write`", + "operationId": "generate_series_thumbnail", "parameters": [ { - "name": "user_id", + "name": "series_id", "in": "path", - "description": "User ID", + "description": "Series ID", "required": true, "schema": { "type": "string", @@ -9261,7 +9419,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateUserRequest" + "$ref": "#/components/schemas/ForceRequest" } } }, @@ -9269,25 +9427,25 @@ }, "responses": { "200": { - "description": "User updated", + "description": "Thumbnail generation task queued", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserDto" + "$ref": "#/components/schemas/CreateTaskResponse" } } } }, "403": { - "description": "Forbidden - Admin only" + "description": "Permission denied" }, "404": { - "description": "User not found" + "description": "Series not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -9295,18 +9453,18 @@ ] } }, - "/api/v1/users/{user_id}/sharing-tags": { - "get": { + "/api/v1/series/{series_id}/unread": { + "post": { "tags": [ - "Sharing Tags" + "Series" ], - "summary": "Get sharing tag grants for a user (admin only)", - "operationId": "get_user_sharing_tags", + "summary": "Mark all books in a series as unread", + "operationId": "mark_series_as_unread", "parameters": [ { - "name": "user_id", + "name": "series_id", "in": "path", - "description": "User ID", + "description": "Series ID", "required": true, "schema": { "type": "string", @@ -9316,17 +9474,20 @@ ], "responses": { "200": { - "description": "List of sharing tag grants for the user", + "description": "Series marked as unread", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserSharingTagGrantsResponse" + "$ref": "#/components/schemas/MarkReadResponse" } } } }, "403": { - "description": "Forbidden - Missing permission" + "description": "Forbidden" + }, + "404": { + "description": "Series not found" } }, "security": [ @@ -9337,30 +9498,129 @@ "api_key": [] } ] - }, - "put": { + } + }, + "/api/v1/settings/branding": { + "get": { "tags": [ - "Sharing Tags" + "Settings" ], - "summary": "Set a user's sharing tag grant (admin only)", - "operationId": "set_user_sharing_tag", - "parameters": [ + "summary": "Get branding settings (unauthenticated)", + "description": "Returns branding-related settings that are needed on unauthenticated pages\nlike the login screen. This endpoint does not require authentication.", + "operationId": "get_branding_settings", + "responses": { + "200": { + "description": "Branding settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrandingSettingsDto" + }, + "example": { + "application_name": "Codex" + } + } + } + } + } + } + }, + "/api/v1/settings/public": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Get public display settings (authenticated users)", + "description": "Returns non-sensitive settings that affect UI/display behavior.\nThis endpoint is available to all authenticated users, not just admins.", + "operationId": "get_public_settings", + "responses": { + "200": { + "description": "Public settings", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/PublicSettingDto" + }, + "propertyNames": { + "type": "string" + } + }, + "example": { + "display.custom_metadata_template": { + "key": "display.custom_metadata_template", + "value": "{{#if custom_metadata}}## Additional Information\n{{#each custom_metadata}}- **{{@key}}**: {{this}}\n{{/each}}{{/if}}" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ { - "name": "user_id", - "in": "path", - "description": "User ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/setup/initialize": { + "post": { + "tags": [ + "Setup" + ], + "summary": "Initialize application setup by creating the first admin user", + "description": "Creates the first admin user with email verification bypassed and returns a JWT token", + "operationId": "initialize_setup", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InitializeSetupRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Setup initialized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InitializeSetupResponse" + } + } } + }, + "400": { + "description": "Invalid request or setup already completed" + }, + "422": { + "description": "Validation error" } + } + } + }, + "/api/v1/setup/settings": { + "patch": { + "tags": [ + "Setup" ], + "summary": "Configure initial settings (optional step in setup wizard)", + "description": "Allows the newly created admin to configure database settings", + "operationId": "configure_initial_settings", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetUserSharingTagGrantRequest" + "$ref": "#/components/schemas/ConfigureSettingsRequest" } } }, @@ -9368,70 +9628,92 @@ }, "responses": { "200": { - "description": "Sharing tag grant set", + "description": "Settings configured", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserSharingTagGrantDto" + "$ref": "#/components/schemas/ConfigureSettingsResponse" } } } }, "403": { - "description": "Forbidden - Missing permission" - }, - "404": { - "description": "Sharing tag not found" + "description": "Forbidden - Admin only" } }, "security": [ { "jwt_bearer": [] - }, - { - "api_key": [] } ] } }, - "/api/v1/users/{user_id}/sharing-tags/{tag_id}": { - "delete": { + "/api/v1/setup/status": { + "get": { "tags": [ - "Sharing Tags" + "Setup" ], - "summary": "Remove a user's sharing tag grant (admin only)", - "operationId": "remove_user_sharing_tag", + "summary": "Check if initial setup is required", + "description": "Returns whether the application needs initial setup (no users exist)", + "operationId": "setup_status", + "responses": { + "200": { + "description": "Setup status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetupStatusResponse" + } + } + } + } + } + } + }, + "/api/v1/tags": { + "get": { + "tags": [ + "Tags" + ], + "summary": "List all tags", + "operationId": "list_tags", "parameters": [ { - "name": "user_id", - "in": "path", - "description": "User ID", - "required": true, + "name": "page", + "in": "query", + "description": "Page number (1-indexed, default 1)", + "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "format": "int64", + "minimum": 0 } }, { - "name": "tag_id", - "in": "path", - "description": "Sharing tag ID", - "required": true, + "name": "pageSize", + "in": "query", + "description": "Number of items per page (default 50, max 500)", + "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "format": "int64", + "minimum": 0 } } ], "responses": { - "204": { - "description": "Sharing tag grant removed" + "200": { + "description": "List of all tags", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse_TagDto" + } + } + } }, "403": { - "description": "Forbidden - Missing permission" - }, - "404": { - "description": "Grant not found" + "description": "Forbidden" } }, "security": [ @@ -9444,41 +9726,26 @@ ] } }, - "/health": { - "get": { - "tags": [ - "Health" - ], - "summary": "Health check endpoint - checks database connectivity", - "description": "Returns \"OK\" with 200 status if database is healthy,\nor \"Service Unavailable\" with 503 status if database check fails.", - "operationId": "health_check", - "responses": { - "200": { - "description": "Service is healthy" - }, - "503": { - "description": "Service is unavailable" - } - } - } - }, - "/opds": { - "get": { + "/api/v1/tags/cleanup": { + "post": { "tags": [ - "OPDS" + "Tags" ], - "summary": "Root OPDS catalog", - "description": "Returns the main navigation feed with links to:\n- All libraries\n- Search\n- Recent additions", - "operationId": "root_catalog", + "summary": "Delete all unused tags (tags with no series linked)", + "operationId": "cleanup_tags", "responses": { "200": { - "description": "OPDS root catalog", + "description": "Cleanup completed", "content": { - "application/atom+xml": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaxonomyCleanupResponse" + } + } } }, "403": { - "description": "Forbidden" + "description": "Forbidden - admin only" } }, "security": [ @@ -9491,19 +9758,18 @@ ] } }, - "/opds/books/{book_id}/pages": { - "get": { + "/api/v1/tags/{tag_id}": { + "delete": { "tags": [ - "OPDS" + "Tags" ], - "summary": "OPDS-PSE: List all pages in a book", - "description": "Returns a PSE page feed with individual page links for streaming.\nThis allows OPDS clients to read books page-by-page without downloading the entire file.", - "operationId": "opds_book_pages", + "summary": "Delete a tag from the taxonomy (admin only)", + "operationId": "delete_tag", "parameters": [ { - "name": "book_id", + "name": "tag_id", "in": "path", - "description": "Book ID", + "description": "Tag ID", "required": true, "schema": { "type": "string", @@ -9512,46 +9778,14 @@ } ], "responses": { - "200": { - "description": "OPDS-PSE page feed", - "content": { - "application/atom+xml": {} - } + "204": { + "description": "Tag deleted" }, "403": { - "description": "Forbidden" + "description": "Forbidden - admin only" }, "404": { - "description": "Book not found" - } - }, - "security": [ - { - "jwt_bearer": [] - }, - { - "api_key": [] - } - ] - } - }, - "/opds/libraries": { - "get": { - "tags": [ - "OPDS" - ], - "summary": "List all libraries", - "description": "Returns a navigation feed with all available libraries", - "operationId": "opds_list_libraries", - "responses": { - "200": { - "description": "OPDS libraries feed", - "content": { - "application/atom+xml": {} - } - }, - "403": { - "description": "Forbidden" + "description": "Tag not found" } }, "security": [ @@ -9564,103 +9798,113 @@ ] } }, - "/opds/libraries/{library_id}": { + "/api/v1/tasks": { "get": { "tags": [ - "OPDS" + "Task Queue" ], - "summary": "List series in a library", - "description": "Returns an acquisition feed with all series in the specified library", - "operationId": "opds_library_series", + "summary": "List tasks with optional filtering", + "description": "# Permission Required\n- `tasks:read`", + "operationId": "list_tasks", "parameters": [ { - "name": "library_id", - "in": "path", - "description": "Library ID", - "required": true, + "name": "status", + "in": "query", + "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": [ + "string", + "null" + ] } }, { - "name": "page", + "name": "taskType", "in": "query", "required": false, "schema": { - "type": "integer", - "format": "int32", - "minimum": 0 + "type": [ + "string", + "null" + ] } }, { - "name": "pageSize", + "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", - "format": "int32", + "format": "int64", "minimum": 0 } } ], "responses": { "200": { - "description": "OPDS library series feed", + "description": "Tasks retrieved successfully", "content": { - "application/atom+xml": {} + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskResponse" + } + } + } } }, "403": { - "description": "Forbidden" - }, - "404": { - "description": "Library not found" + "description": "Permission denied" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - } - }, - "/opds/search": { - "get": { + }, + "post": { "tags": [ - "OPDS" + "Task Queue" ], - "summary": "OPDS search endpoint", - "description": "Searches books and series by title and returns an OPDS acquisition feed", - "operationId": "opds_search", - "parameters": [ - { - "name": "q", - "in": "query", - "description": "Search query string", - "required": true, - "schema": { - "type": "string" + "summary": "Create a new task", + "description": "# Permission Required\n- `tasks:write`", + "operationId": "create_task", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTaskRequest" + } } - } - ], + }, + "required": true + }, "responses": { "200": { - "description": "OPDS search results", + "description": "Task created successfully", "content": { - "application/atom+xml": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTaskResponse" + } + } } }, + "400": { + "description": "Invalid request" + }, "403": { - "description": "Forbidden" + "description": "Permission denied" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -9668,25 +9912,32 @@ ] } }, - "/opds/search.xml": { - "get": { + "/api/v1/tasks/nuke": { + "delete": { "tags": [ - "OPDS" + "Task Queue" ], - "summary": "OpenSearch descriptor endpoint", - "description": "Returns the OpenSearch XML descriptor for OPDS clients", - "operationId": "opds_opensearch_descriptor", + "summary": "Nuclear option: Delete ALL tasks", + "description": "# Permission Required\n- `admin`", + "operationId": "nuke_all_tasks", "responses": { "200": { - "description": "OpenSearch descriptor", + "description": "All tasks deleted", "content": { - "application/opensearchdescription+xml": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/PurgeTasksResponse" + } + } } + }, + "403": { + "description": "Permission denied (admin only)" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -9694,43 +9945,44 @@ ] } }, - "/opds/series/{series_id}": { - "get": { + "/api/v1/tasks/purge": { + "delete": { "tags": [ - "OPDS" + "Task Queue" ], - "summary": "List books in a series", - "description": "Returns an acquisition feed with all books in the specified series", - "operationId": "opds_series_books", + "summary": "Purge old completed/failed tasks", + "description": "# Permission Required\n- `tasks:write`", + "operationId": "purge_old_tasks", "parameters": [ { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, + "name": "days", + "in": "query", + "description": "Delete tasks older than N days (default: 30)", + "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "format": "int64" } } ], "responses": { "200": { - "description": "OPDS series books feed", + "description": "Tasks purged successfully", "content": { - "application/atom+xml": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/PurgeTasksResponse" + } + } } }, "403": { - "description": "Forbidden" - }, - "404": { - "description": "Series not found" + "description": "Permission denied" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -9738,32 +9990,32 @@ ] } }, - "/opds/v2": { + "/api/v1/tasks/stats": { "get": { "tags": [ - "OPDS 2.0" + "Task Queue" ], - "summary": "Root OPDS 2.0 catalog", - "description": "Returns the main navigation feed with links to:\n- All libraries\n- Search\n- Recent additions", - "operationId": "opds2_root", + "summary": "Get queue statistics", + "description": "# Permission Required\n- `tasks:read`", + "operationId": "get_task_stats", "responses": { "200": { - "description": "OPDS 2.0 root catalog", + "description": "Statistics retrieved successfully", "content": { - "application/opds+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Opds2Feed" + "$ref": "#/components/schemas/TaskStats" } } } }, "403": { - "description": "Forbidden" + "description": "Permission denied" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -9771,27 +10023,26 @@ ] } }, - "/opds/v2/libraries": { + "/api/v1/tasks/stream": { "get": { "tags": [ - "OPDS 2.0" + "Events" ], - "summary": "List all libraries (OPDS 2.0)", - "description": "Returns a navigation feed with all available libraries", - "operationId": "opds2_libraries", + "summary": "Subscribe to real-time task progress events via SSE", + "description": "Clients can subscribe to this endpoint to receive real-time notifications\nabout background task progress (analyze_book, generate_thumbnails, etc.).\n\n## Authentication\nRequires valid authentication with `LibrariesRead` permission.\n\n## Event Format\nEvents are sent as JSON-encoded `TaskProgressEvent` objects with the following structure:\n```json\n{\n \"task_id\": \"uuid\",\n \"task_type\": \"analyze_book\",\n \"status\": \"running\",\n \"progress\": {\n \"current\": 5,\n \"total\": 10,\n \"message\": \"Processing book 5 of 10\"\n },\n \"started_at\": \"2024-01-06T12:00:00Z\",\n \"library_id\": \"uuid\"\n}\n```\n\n## Keep-Alive\nA keep-alive message is sent every 15 seconds to prevent connection timeout.", + "operationId": "task_progress_stream", "responses": { "200": { - "description": "OPDS 2.0 libraries feed", + "description": "SSE stream of task progress events", "content": { - "application/opds+json": { - "schema": { - "$ref": "#/components/schemas/Opds2Feed" - } - } + "text/event-stream": {} } }, - "403": { - "description": "Forbidden" + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" } }, "security": [ @@ -9804,67 +10055,47 @@ ] } }, - "/opds/v2/libraries/{library_id}": { + "/api/v1/tasks/{task_id}": { "get": { "tags": [ - "OPDS 2.0" + "Task Queue" ], - "summary": "List series in a library (OPDS 2.0)", - "description": "Returns a navigation feed with all series in the specified library", - "operationId": "opds2_library_series", + "summary": "Get task by ID", + "description": "# Permission Required\n- `tasks:read`", + "operationId": "get_task", "parameters": [ { - "name": "library_id", + "name": "task_id", "in": "path", - "description": "Library ID", + "description": "Task ID", "required": true, "schema": { "type": "string", "format": "uuid" } - }, - { - "name": "page", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "format": "int32", - "minimum": 0 - } - }, - { - "name": "pageSize", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "format": "int32", - "minimum": 0 - } } ], "responses": { "200": { - "description": "OPDS 2.0 library series feed", + "description": "Task retrieved successfully", "content": { - "application/opds+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Opds2Feed" + "$ref": "#/components/schemas/TaskResponse" } } } }, "403": { - "description": "Forbidden" + "description": "Permission denied" }, "404": { - "description": "Library not found" + "description": "Task not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -9872,54 +10103,50 @@ ] } }, - "/opds/v2/recent": { - "get": { + "/api/v1/tasks/{task_id}/cancel": { + "post": { "tags": [ - "OPDS 2.0" + "Task Queue" ], - "summary": "List recent additions (OPDS 2.0)", - "description": "Returns a publications feed with recently added books", - "operationId": "opds2_recent", + "summary": "Cancel a task", + "description": "# Permission Required\n- `tasks:write`", + "operationId": "cancel_task", "parameters": [ { - "name": "page", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "format": "int32", - "minimum": 0 - } - }, - { - "name": "pageSize", - "in": "query", - "required": false, + "name": "task_id", + "in": "path", + "description": "Task ID", + "required": true, "schema": { - "type": "integer", - "format": "int32", - "minimum": 0 + "type": "string", + "format": "uuid" } } ], "responses": { "200": { - "description": "OPDS 2.0 recent additions feed", + "description": "Task cancelled successfully", "content": { - "application/opds+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Opds2Feed" + "$ref": "#/components/schemas/MessageResponse" } } } }, + "400": { + "description": "Task cannot be cancelled" + }, "403": { - "description": "Forbidden" + "description": "Permission denied" + }, + "404": { + "description": "Task not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -9927,46 +10154,50 @@ ] } }, - "/opds/v2/search": { - "get": { + "/api/v1/tasks/{task_id}/retry": { + "post": { "tags": [ - "OPDS 2.0" + "Task Queue" ], - "summary": "OPDS 2.0 search endpoint", - "description": "Searches books and series by title and returns an OPDS 2.0 publications feed", - "operationId": "opds2_search", + "summary": "Retry a failed task", + "description": "# Permission Required\n- `tasks:write`", + "operationId": "retry_task", "parameters": [ { - "name": "query", - "in": "query", - "description": "Search query string", + "name": "task_id", + "in": "path", + "description": "Task ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], "responses": { "200": { - "description": "OPDS 2.0 search results", + "description": "Task queued for retry", "content": { - "application/opds+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Opds2Feed" + "$ref": "#/components/schemas/MessageResponse" } } } }, "400": { - "description": "Bad request - empty query" + "description": "Task is not in failed state" }, "403": { - "description": "Forbidden" + "description": "Permission denied" + }, + "404": { + "description": "Task not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -9974,19 +10205,19 @@ ] } }, - "/opds/v2/series/{series_id}": { - "get": { + "/api/v1/tasks/{task_id}/unlock": { + "post": { "tags": [ - "OPDS 2.0" + "Task Queue" ], - "summary": "List books in a series (OPDS 2.0)", - "description": "Returns a publications feed with all books in the specified series", - "operationId": "opds2_series_books", + "summary": "Unlock a stuck task", + "description": "# Permission Required\n- `tasks:write`", + "operationId": "unlock_task", "parameters": [ { - "name": "series_id", + "name": "task_id", "in": "path", - "description": "Series ID", + "description": "Task ID", "required": true, "schema": { "type": "string", @@ -9996,25 +10227,25 @@ ], "responses": { "200": { - "description": "OPDS 2.0 series books feed", + "description": "Task unlocked successfully", "content": { - "application/opds+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Opds2Feed" + "$ref": "#/components/schemas/MessageResponse" } } } }, "403": { - "description": "Forbidden" + "description": "Permission denied" }, "404": { - "description": "Series not found" + "description": "Task not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -10022,62 +10253,48 @@ ] } }, - "/{prefix}/api/v1/books/list": { - "post": { + "/api/v1/user/preferences": { + "get": { "tags": [ - "Komga" + "User Preferences" ], - "summary": "Search/filter books", - "description": "Returns books matching the filter criteria.\nThis uses POST to support complex filter bodies.\n\n## Endpoint\n`POST /{prefix}/api/v1/books/list`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n- `sort` - Sort parameter (e.g., \"createdDate,desc\")\n\n## Request Body\nJSON object with filter criteria (library_id, series_id, search_term, etc.)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_search_books", - "parameters": [ - { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "page", - "in": "query", - "description": "Page number (0-indexed, Komga-style)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" + "summary": "Get all preferences for the authenticated user", + "operationId": "get_all_preferences", + "responses": { + "200": { + "description": "User preferences retrieved", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPreferencesResponse" + } + } } }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ { - "name": "size", - "in": "query", - "description": "Page size (default: 20)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" - } + "bearer_auth": [] }, { - "name": "sort", - "in": "query", - "description": "Sort parameter (e.g., \"createdDate,desc\", \"metadata.numberSort,asc\")", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } + "api_key": [] } + ] + }, + "put": { + "tags": [ + "User Preferences" ], + "summary": "Set multiple preferences at once", + "operationId": "set_bulk_preferences", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KomgaBooksSearchRequestDto" + "$ref": "#/components/schemas/BulkSetPreferencesRequest" } } }, @@ -10085,22 +10302,25 @@ }, "responses": { "200": { - "description": "Paginated list of books matching filter", + "description": "Preferences updated successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KomgaPage_KomgaBookDto" + "$ref": "#/components/schemas/SetPreferencesResponse" } } } }, + "400": { + "description": "Invalid preference key or value" + }, "401": { "description": "Unauthorized" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -10108,185 +10328,140 @@ ] } }, - "/{prefix}/api/v1/books/ondeck": { + "/api/v1/user/preferences/{key}": { "get": { "tags": [ - "Komga" + "User Preferences" ], - "summary": "Get \"on deck\" books", - "description": "Returns books that are currently in-progress (started but not completed).\nThis is the \"continue reading\" shelf in Komic.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/ondeck`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_books_ondeck", + "summary": "Get a single preference by key", + "operationId": "get_preference", "parameters": [ { - "name": "prefix", + "name": "key", "in": "path", - "description": "Komga API prefix (default: komga)", + "description": "Preference key (e.g., 'ui.theme')", "required": true, "schema": { "type": "string" } - }, - { - "name": "page", - "in": "query", - "description": "Page number (0-indexed, Komga-style)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "size", - "in": "query", - "description": "Page size (default: 20)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "sort", - "in": "query", - "description": "Sort parameter (e.g., \"createdDate,desc\", \"metadata.numberSort,asc\")", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } } ], "responses": { "200": { - "description": "Paginated list of in-progress books", + "description": "Preference retrieved", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KomgaPage_KomgaBookDto" + "$ref": "#/components/schemas/UserPreferenceDto" } } } }, "401": { "description": "Unauthorized" + }, + "404": { + "description": "Preference not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - } - }, - "/{prefix}/api/v1/books/{book_id}": { - "get": { + }, + "put": { "tags": [ - "Komga" + "User Preferences" ], - "summary": "Get a book by ID", - "description": "Returns a single book in Komga-compatible format.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_book", + "summary": "Set a single preference value", + "operationId": "set_preference", "parameters": [ { - "name": "prefix", + "name": "key", "in": "path", - "description": "Komga API prefix (default: komga)", + "description": "Preference key (e.g., 'ui.theme')", "required": true, "schema": { "type": "string" } - }, - { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetPreferenceRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Book details", + "description": "Preference set successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KomgaBookDto" + "$ref": "#/components/schemas/UserPreferenceDto" } } } }, + "400": { + "description": "Invalid preference value" + }, "401": { "description": "Unauthorized" - }, - "404": { - "description": "Book not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - } - }, - "/{prefix}/api/v1/books/{book_id}/file": { - "get": { + }, + "delete": { "tags": [ - "Komga" + "User Preferences" ], - "summary": "Download book file", - "description": "Streams the original book file (CBZ, CBR, EPUB, PDF) for download.\nIncludes proper Content-Disposition header with UTF-8 encoding.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/file`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_download_book_file", + "summary": "Delete (reset) a preference to its default", + "operationId": "delete_preference", "parameters": [ { - "name": "prefix", + "name": "key", "in": "path", - "description": "Komga API prefix (default: komga)", + "description": "Preference key to delete", "required": true, "schema": { "type": "string" } - }, - { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } } ], "responses": { "200": { - "description": "Book file download", + "description": "Preference deleted", "content": { - "application/octet-stream": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeletePreferenceResponse" + } + } } }, "401": { "description": "Unauthorized" - }, - "404": { - "description": "Book not found or file missing" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -10294,51 +10469,26 @@ ] } }, - "/{prefix}/api/v1/books/{book_id}/next": { + "/api/v1/user/ratings": { "get": { "tags": [ - "Komga" - ], - "summary": "Get next book in series", - "description": "Returns the next book in the same series by sort order.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/next`\n\n## Response\n- 200: Next book DTO\n- 404: No next book (this is the last book in series)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_next_book", - "parameters": [ - { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Ratings" ], + "summary": "List all of the current user's ratings", + "operationId": "list_user_ratings", "responses": { "200": { - "description": "Next book in series", + "description": "List of user's ratings", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KomgaBookDto" + "$ref": "#/components/schemas/UserRatingsListResponse" } } } }, - "401": { - "description": "Unauthorized" - }, - "404": { - "description": "No next book" + "403": { + "description": "Forbidden" } }, "security": [ @@ -10351,54 +10501,23 @@ ] } }, - "/{prefix}/api/v1/books/{book_id}/pages": { + "/api/v1/user/sharing-tags": { "get": { "tags": [ - "Komga" - ], - "summary": "List all pages for a book", - "description": "Returns an array of page metadata for all pages in a book.\nPages are ordered by page number (1-indexed).\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/pages`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key\n\n## Response\nReturns an array of `KomgaPageDto` objects with page metadata including\nfilename, MIME type, dimensions, and size.", - "operationId": "komga_list_pages", - "parameters": [ - { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Sharing Tags" ], + "summary": "Get current user's sharing tag grants", + "operationId": "get_my_sharing_tags", "responses": { "200": { - "description": "List of pages in the book", + "description": "List of sharing tag grants for the current user", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KomgaPageDto" - } + "$ref": "#/components/schemas/UserSharingTagGrantsResponse" } } } - }, - "401": { - "description": "Unauthorized" - }, - "404": { - "description": "Book not found" } }, "security": [ @@ -10411,57 +10530,90 @@ ] } }, - "/{prefix}/api/v1/books/{book_id}/pages/{page_number}": { + "/api/v1/users": { "get": { "tags": [ - "Komga" + "Users" ], - "summary": "Get a specific page image", - "description": "Streams the raw page image for the requested page number.\nPage numbers are 1-indexed.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/pages/{pageNumber}`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key (via cookie fallback for browser image tags)\n\n## Response\nReturns the raw image data with appropriate Content-Type header.\nResponse is cached for 1 year (immutable content).", - "operationId": "komga_get_page", + "summary": "List all users (admin only) with pagination and filtering", + "operationId": "list_users", "parameters": [ { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, + "name": "role", + "in": "query", + "description": "Filter by role", + "required": false, "schema": { - "type": "string" + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/UserRole" + } + ] } }, { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, + "name": "sharingTag", + "in": "query", + "description": "Filter by sharing tag name (users who have a grant for this tag)", + "required": false, "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "page_number", - "in": "path", - "description": "Page number (1-indexed)", - "required": true, + "type": [ + "string", + "null" + ] + } + }, + { + "name": "sharingTagMode", + "in": "query", + "description": "Filter by sharing tag access mode (allow/deny) - only used with sharing_tag", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "page", + "in": "query", + "description": "Page number (1-indexed, default 1)", + "required": false, "schema": { "type": "integer", - "format": "int32" + "format": "int64", + "minimum": 0 + } + }, + { + "name": "pageSize", + "in": "query", + "description": "Number of items per page (max 100, default 50)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 } } ], "responses": { "200": { - "description": "Page image", + "description": "Paginated list of users", "content": { - "image/*": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse_UserDto" + } + } } }, - "401": { - "description": "Unauthorized" - }, - "404": { - "description": "Book or page not found" + "403": { + "description": "Forbidden - Admin only" } }, "security": [ @@ -10472,59 +10624,39 @@ "api_key": [] } ] - } - }, - "/{prefix}/api/v1/books/{book_id}/pages/{page_number}/thumbnail": { - "get": { + }, + "post": { "tags": [ - "Komga" + "Users" ], - "summary": "Get a page thumbnail", - "description": "Returns a thumbnail version of the requested page.\nThumbnails are resized to max 300px width/height while maintaining aspect ratio.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/pages/{pageNumber}/thumbnail`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key (via cookie fallback for browser image tags)\n\n## Response\nReturns a JPEG thumbnail with appropriate caching headers.", - "operationId": "komga_get_page_thumbnail", - "parameters": [ - { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" + "summary": "Create a new user (admin only)", + "operationId": "create_user", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUserRequest" + } } }, - { - "name": "page_number", - "in": "path", - "description": "Page number (1-indexed)", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - } - ], + "required": true + }, "responses": { - "200": { - "description": "Page thumbnail image", + "201": { + "description": "User created", "content": { - "image/jpeg": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } } }, - "401": { - "description": "Unauthorized" + "400": { + "description": "Invalid request" }, - "404": { - "description": "Book or page not found" + "403": { + "description": "Forbidden - Admin only" } }, "security": [ @@ -10537,28 +10669,18 @@ ] } }, - "/{prefix}/api/v1/books/{book_id}/previous": { + "/api/v1/users/{user_id}": { "get": { "tags": [ - "Komga" + "Users" ], - "summary": "Get previous book in series", - "description": "Returns the previous book in the same series by sort order.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/previous`\n\n## Response\n- 200: Previous book DTO\n- 404: No previous book (this is the first book in series)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_previous_book", + "summary": "Get user by ID (admin only)", + "operationId": "get_user", "parameters": [ { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "book_id", + "name": "user_id", "in": "path", - "description": "Book ID", + "description": "User ID", "required": true, "schema": { "type": "string", @@ -10568,20 +10690,20 @@ ], "responses": { "200": { - "description": "Previous book in series", + "description": "User details with sharing tags", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KomgaBookDto" + "$ref": "#/components/schemas/UserDetailDto" } } } }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden - Admin only" }, "404": { - "description": "No previous book" + "description": "User not found" } }, "security": [ @@ -10592,30 +10714,18 @@ "api_key": [] } ] - } - }, - "/{prefix}/api/v1/books/{book_id}/read-progress": { + }, "delete": { "tags": [ - "Komga" + "Users" ], - "summary": "Delete reading progress for a book (mark as unread)", - "description": "Removes all reading progress for a book, effectively marking it as unread.\n\n## Endpoint\n`DELETE /{prefix}/api/v1/books/{bookId}/read-progress`\n\n## Response\n- 204 No Content on success\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_delete_progress", + "summary": "Delete a user (admin only)", + "operationId": "delete_user", "parameters": [ { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "book_id", + "name": "user_id", "in": "path", - "description": "Book ID", + "description": "User ID", "required": true, "schema": { "type": "string", @@ -10625,13 +10735,13 @@ ], "responses": { "204": { - "description": "Progress deleted successfully" + "description": "User deleted" }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden - Admin only" }, "404": { - "description": "Book not found" + "description": "User not found" } }, "security": [ @@ -10645,25 +10755,15 @@ }, "patch": { "tags": [ - "Komga" + "Users" ], - "summary": "Update reading progress for a book", - "description": "Updates the user's reading progress for a specific book.\nKomic sends: `{ \"completed\": false, \"page\": 151 }`\n\n## Endpoint\n`PATCH /{prefix}/api/v1/books/{bookId}/read-progress`\n\n## Request Body\n- `page` - Current page number (1-indexed, optional)\n- `completed` - Whether book is completed (optional)\n- `device_id` - Device ID (optional, not used by Komic)\n- `device_name` - Device name (optional, not used by Komic)\n\n## Response\n- 204 No Content on success (Komga behavior)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_update_progress", + "summary": "Update a user (admin only, partial update)", + "operationId": "update_user", "parameters": [ { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "book_id", + "name": "user_id", "in": "path", - "description": "Book ID", + "description": "User ID", "required": true, "schema": { "type": "string", @@ -10675,21 +10775,28 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KomgaReadProgressUpdateDto" + "$ref": "#/components/schemas/UpdateUserRequest" } } }, "required": true }, "responses": { - "204": { - "description": "Progress updated successfully" + "200": { + "description": "User updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + } }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden - Admin only" }, "404": { - "description": "Book not found" + "description": "User not found" } }, "security": [ @@ -10702,28 +10809,18 @@ ] } }, - "/{prefix}/api/v1/books/{book_id}/thumbnail": { + "/api/v1/users/{user_id}/sharing-tags": { "get": { "tags": [ - "Komga" + "Sharing Tags" ], - "summary": "Get book thumbnail", - "description": "Returns a thumbnail image for the book's first page.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/thumbnail`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_book_thumbnail", + "summary": "Get sharing tag grants for a user (admin only)", + "operationId": "get_user_sharing_tags", "parameters": [ { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "book_id", + "name": "user_id", "in": "path", - "description": "Book ID", + "description": "User ID", "required": true, "schema": { "type": "string", @@ -10733,16 +10830,17 @@ ], "responses": { "200": { - "description": "Book thumbnail image", + "description": "List of sharing tag grants for the user", "content": { - "image/jpeg": {} - } - }, - "401": { - "description": "Unauthorized" + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserSharingTagGrantsResponse" + } + } + } }, - "404": { - "description": "Book not found or has no pages" + "403": { + "description": "Forbidden - Missing permission" } }, "security": [ @@ -10753,43 +10851,51 @@ "api_key": [] } ] - } - }, - "/{prefix}/api/v1/libraries": { - "get": { + }, + "put": { "tags": [ - "Komga" + "Sharing Tags" ], - "summary": "List all libraries", - "description": "Returns all libraries in Komga-compatible format.\n\n## Endpoint\n`GET /{prefix}/api/v1/libraries`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_list_libraries", + "summary": "Set a user's sharing tag grant (admin only)", + "operationId": "set_user_sharing_tag", "parameters": [ { - "name": "prefix", + "name": "user_id", "in": "path", - "description": "Komga API prefix (default: komga)", + "description": "User ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetUserSharingTagGrantRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "List of libraries", + "description": "Sharing tag grant set", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KomgaLibraryDto" - } + "$ref": "#/components/schemas/UserSharingTagGrantDto" } } } }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden - Missing permission" + }, + "404": { + "description": "Sharing tag not found" } }, "security": [ @@ -10802,28 +10908,28 @@ ] } }, - "/{prefix}/api/v1/libraries/{library_id}": { - "get": { + "/api/v1/users/{user_id}/sharing-tags/{tag_id}": { + "delete": { "tags": [ - "Komga" + "Sharing Tags" ], - "summary": "Get library by ID", - "description": "Returns a single library in Komga-compatible format.\n\n## Endpoint\n`GET /{prefix}/api/v1/libraries/{libraryId}`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_library", + "summary": "Remove a user's sharing tag grant (admin only)", + "operationId": "remove_user_sharing_tag", "parameters": [ { - "name": "prefix", + "name": "user_id", "in": "path", - "description": "Komga API prefix (default: komga)", + "description": "User ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } }, { - "name": "library_id", + "name": "tag_id", "in": "path", - "description": "Library ID", + "description": "Sharing tag ID", "required": true, "schema": { "type": "string", @@ -10832,21 +10938,14 @@ } ], "responses": { - "200": { - "description": "Library details", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/KomgaLibraryDto" - } - } - } + "204": { + "description": "Sharing tag grant removed" }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden - Missing permission" }, "404": { - "description": "Library not found" + "description": "Grant not found" } }, "security": [ @@ -10859,28 +10958,66 @@ ] } }, - "/{prefix}/api/v1/libraries/{library_id}/thumbnail": { + "/health": { "get": { "tags": [ - "Komga" + "Health" ], - "summary": "Get library thumbnail", - "description": "Returns a thumbnail image for the library. Uses the first series' cover\nas the library thumbnail, or returns a 404 if no series exist.\n\n## Endpoint\n`GET /{prefix}/api/v1/libraries/{libraryId}/thumbnail`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key (via cookie fallback for browser image tags)", - "operationId": "komga_get_library_thumbnail", - "parameters": [ - { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" + "summary": "Health check endpoint - checks database connectivity", + "description": "Returns \"OK\" with 200 status if database is healthy,\nor \"Service Unavailable\" with 503 status if database check fails.", + "operationId": "health_check", + "responses": { + "200": { + "description": "Service is healthy" + }, + "503": { + "description": "Service is unavailable" + } + } + } + }, + "/opds": { + "get": { + "tags": [ + "OPDS" + ], + "summary": "Root OPDS catalog", + "description": "Returns the main navigation feed with links to:\n- All libraries\n- Search\n- Recent additions", + "operationId": "root_catalog", + "responses": { + "200": { + "description": "OPDS root catalog", + "content": { + "application/atom+xml": {} } }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "jwt_bearer": [] + }, { - "name": "library_id", + "api_key": [] + } + ] + } + }, + "/opds/books/{book_id}/pages": { + "get": { + "tags": [ + "OPDS" + ], + "summary": "OPDS-PSE: List all pages in a book", + "description": "Returns a PSE page feed with individual page links for streaming.\nThis allows OPDS clients to read books page-by-page without downloading the entire file.", + "operationId": "opds_book_pages", + "parameters": [ + { + "name": "book_id", "in": "path", - "description": "Library ID", + "description": "Book ID", "required": true, "schema": { "type": "string", @@ -10890,16 +11027,16 @@ ], "responses": { "200": { - "description": "Library thumbnail image", + "description": "OPDS-PSE page feed", "content": { - "image/jpeg": {} + "application/atom+xml": {} } }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden" }, "404": { - "description": "Library not found or no series in library" + "description": "Book not found" } }, "security": [ @@ -10912,100 +11049,92 @@ ] } }, - "/{prefix}/api/v1/series": { + "/opds/libraries": { "get": { "tags": [ - "Komga" + "OPDS" ], - "summary": "List all series (paginated)", - "description": "Returns all series in Komga-compatible format with pagination.\n\n## Endpoint\n`GET /{prefix}/api/v1/series`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n- `library_id` - Optional filter by library UUID\n- `search` - Optional search query\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_list_series", + "summary": "List all libraries", + "description": "Returns a navigation feed with all available libraries", + "operationId": "opds_list_libraries", + "responses": { + "200": { + "description": "OPDS libraries feed", + "content": { + "application/atom+xml": {} + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/opds/libraries/{library_id}": { + "get": { + "tags": [ + "OPDS" + ], + "summary": "List series in a library", + "description": "Returns an acquisition feed with all series in the specified library", + "operationId": "opds_library_series", "parameters": [ { - "name": "prefix", + "name": "library_id", "in": "path", - "description": "Komga API prefix (default: komga)", + "description": "Library ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } }, { "name": "page", "in": "query", - "description": "Page number (0-indexed, Komga-style)", "required": false, "schema": { "type": "integer", - "format": "int32" + "format": "int32", + "minimum": 0 } }, { - "name": "size", + "name": "pageSize", "in": "query", - "description": "Page size (default: 20)", "required": false, "schema": { "type": "integer", - "format": "int32" + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "OPDS library series feed", + "content": { + "application/atom+xml": {} } }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Library not found" + } + }, + "security": [ { - "name": "library_id", - "in": "query", - "description": "Filter by library ID", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "search", - "in": "query", - "description": "Search query", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "sort", - "in": "query", - "description": "Sort parameter (e.g., \"metadata.titleSort,asc\", \"createdDate,desc\")", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - } - ], - "responses": { - "200": { - "description": "Paginated list of series", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/KomgaPage_KomgaSeriesDto" - } - } - } - }, - "401": { - "description": "Unauthorized" - } - }, - "security": [ - { - "jwt_bearer": [] + "jwt_bearer": [] }, { "api_key": [] @@ -11013,95 +11142,34 @@ ] } }, - "/{prefix}/api/v1/series/new": { + "/opds/search": { "get": { "tags": [ - "Komga" + "OPDS" ], - "summary": "Get recently added series", - "description": "Returns series sorted by created date descending (newest first).\n\n## Endpoint\n`GET /{prefix}/api/v1/series/new`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n- `library_id` - Optional filter by library UUID\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_series_new", + "summary": "OPDS search endpoint", + "description": "Searches books and series by title and returns an OPDS acquisition feed", + "operationId": "opds_search", "parameters": [ { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", + "name": "q", + "in": "query", + "description": "Search query string", "required": true, "schema": { "type": "string" } - }, - { - "name": "page", - "in": "query", - "description": "Page number (0-indexed, Komga-style)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "size", - "in": "query", - "description": "Page size (default: 20)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "library_id", - "in": "query", - "description": "Filter by library ID", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "search", - "in": "query", - "description": "Search query", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "sort", - "in": "query", - "description": "Sort parameter (e.g., \"metadata.titleSort,asc\", \"createdDate,desc\")", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } } ], "responses": { "200": { - "description": "Paginated list of recently added series", + "description": "OPDS search results", "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/KomgaPage_KomgaSeriesDto" - } - } + "application/atom+xml": {} } }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden" } }, "security": [ @@ -11114,95 +11182,20 @@ ] } }, - "/{prefix}/api/v1/series/updated": { + "/opds/search.xml": { "get": { "tags": [ - "Komga" - ], - "summary": "Get recently updated series", - "description": "Returns series sorted by last modified date descending (most recently updated first).\n\n## Endpoint\n`GET /{prefix}/api/v1/series/updated`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n- `library_id` - Optional filter by library UUID\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_series_updated", - "parameters": [ - { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "page", - "in": "query", - "description": "Page number (0-indexed, Komga-style)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "size", - "in": "query", - "description": "Page size (default: 20)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "library_id", - "in": "query", - "description": "Filter by library ID", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "search", - "in": "query", - "description": "Search query", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "sort", - "in": "query", - "description": "Sort parameter (e.g., \"metadata.titleSort,asc\", \"createdDate,desc\")", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - } + "OPDS" ], + "summary": "OpenSearch descriptor endpoint", + "description": "Returns the OpenSearch XML descriptor for OPDS clients", + "operationId": "opds_opensearch_descriptor", "responses": { "200": { - "description": "Paginated list of recently updated series", + "description": "OpenSearch descriptor", "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/KomgaPage_KomgaSeriesDto" - } - } + "application/opensearchdescription+xml": {} } - }, - "401": { - "description": "Unauthorized" } }, "security": [ @@ -11215,24 +11208,15 @@ ] } }, - "/{prefix}/api/v1/series/{series_id}": { + "/opds/series/{series_id}": { "get": { "tags": [ - "Komga" + "OPDS" ], - "summary": "Get series by ID", - "description": "Returns a single series in Komga-compatible format.\n\n## Endpoint\n`GET /{prefix}/api/v1/series/{seriesId}`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_series", + "summary": "List books in a series", + "description": "Returns an acquisition feed with all books in the specified series", + "operationId": "opds_series_books", "parameters": [ - { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" - } - }, { "name": "series_id", "in": "path", @@ -11246,17 +11230,13 @@ ], "responses": { "200": { - "description": "Series details", + "description": "OPDS series books feed", "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/KomgaSeriesDto" - } - } + "application/atom+xml": {} } }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden" }, "404": { "description": "Series not found" @@ -11272,28 +11252,85 @@ ] } }, - "/{prefix}/api/v1/series/{series_id}/books": { + "/opds/v2": { "get": { "tags": [ - "Komga" + "OPDS 2.0" ], - "summary": "Get books in a series", - "description": "Returns all books in a series with pagination.\n\n## Endpoint\n`GET /{prefix}/api/v1/series/{seriesId}/books`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_series_books", - "parameters": [ + "summary": "Root OPDS 2.0 catalog", + "description": "Returns the main navigation feed with links to:\n- All libraries\n- Search\n- Recent additions", + "operationId": "opds2_root", + "responses": { + "200": { + "description": "OPDS 2.0 root catalog", + "content": { + "application/opds+json": { + "schema": { + "$ref": "#/components/schemas/Opds2Feed" + } + } + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/opds/v2/libraries": { + "get": { + "tags": [ + "OPDS 2.0" + ], + "summary": "List all libraries (OPDS 2.0)", + "description": "Returns a navigation feed with all available libraries", + "operationId": "opds2_libraries", + "responses": { + "200": { + "description": "OPDS 2.0 libraries feed", + "content": { + "application/opds+json": { + "schema": { + "$ref": "#/components/schemas/Opds2Feed" + } + } } }, + "403": { + "description": "Forbidden" + } + }, + "security": [ { - "name": "series_id", + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/opds/v2/libraries/{library_id}": { + "get": { + "tags": [ + "OPDS 2.0" + ], + "summary": "List series in a library (OPDS 2.0)", + "description": "Returns a navigation feed with all series in the specified library", + "operationId": "opds2_library_series", + "parameters": [ + { + "name": "library_id", "in": "path", - "description": "Series ID", + "description": "Library ID", "required": true, "schema": { "type": "string", @@ -11303,77 +11340,95 @@ { "name": "page", "in": "query", - "description": "Page number (0-indexed, Komga-style)", "required": false, "schema": { "type": "integer", - "format": "int32" + "format": "int32", + "minimum": 0 } }, { - "name": "size", + "name": "pageSize", "in": "query", - "description": "Page size (default: 20)", "required": false, "schema": { "type": "integer", - "format": "int32" + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "OPDS 2.0 library series feed", + "content": { + "application/opds+json": { + "schema": { + "$ref": "#/components/schemas/Opds2Feed" + } + } } }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Library not found" + } + }, + "security": [ { - "name": "library_id", - "in": "query", - "description": "Filter by library ID", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } + "jwt_bearer": [] }, { - "name": "search", + "api_key": [] + } + ] + } + }, + "/opds/v2/recent": { + "get": { + "tags": [ + "OPDS 2.0" + ], + "summary": "List recent additions (OPDS 2.0)", + "description": "Returns a publications feed with recently added books", + "operationId": "opds2_recent", + "parameters": [ + { + "name": "page", "in": "query", - "description": "Search query", "required": false, "schema": { - "type": [ - "string", - "null" - ] + "type": "integer", + "format": "int32", + "minimum": 0 } }, { - "name": "sort", + "name": "pageSize", "in": "query", - "description": "Sort parameter (e.g., \"metadata.titleSort,asc\", \"createdDate,desc\")", "required": false, "schema": { - "type": [ - "string", - "null" - ] + "type": "integer", + "format": "int32", + "minimum": 0 } } ], "responses": { "200": { - "description": "Paginated list of books in series", + "description": "OPDS 2.0 recent additions feed", "content": { - "application/json": { + "application/opds+json": { "schema": { - "$ref": "#/components/schemas/KomgaPage_KomgaBookDto" + "$ref": "#/components/schemas/Opds2Feed" } } } }, - "401": { - "description": "Unauthorized" - }, - "404": { - "description": "Series not found" + "403": { + "description": "Forbidden" } }, "security": [ @@ -11386,47 +11441,41 @@ ] } }, - "/{prefix}/api/v1/series/{series_id}/thumbnail": { + "/opds/v2/search": { "get": { "tags": [ - "Komga" + "OPDS 2.0" ], - "summary": "Get series thumbnail", - "description": "Returns a thumbnail image for the series.\n\n## Endpoint\n`GET /{prefix}/api/v1/series/{seriesId}/thumbnail`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_series_thumbnail", + "summary": "OPDS 2.0 search endpoint", + "description": "Searches books and series by title and returns an OPDS 2.0 publications feed", + "operationId": "opds2_search", "parameters": [ { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", + "name": "query", + "in": "query", + "description": "Search query string", "required": true, "schema": { "type": "string" } - }, - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } } ], "responses": { "200": { - "description": "Series thumbnail image", + "description": "OPDS 2.0 search results", "content": { - "image/jpeg": {} + "application/opds+json": { + "schema": { + "$ref": "#/components/schemas/Opds2Feed" + } + } } }, - "401": { - "description": "Unauthorized" + "400": { + "description": "Bad request - empty query" }, - "404": { - "description": "Series not found" + "403": { + "description": "Forbidden" } }, "security": [ @@ -11439,38 +11488,42 @@ ] } }, - "/{prefix}/api/v1/users/me": { + "/opds/v2/series/{series_id}": { "get": { "tags": [ - "Komga" + "OPDS 2.0" ], - "summary": "Get current user information", - "description": "Returns information about the currently authenticated user in Komga format.\nThis endpoint is used by Komic and other apps to verify authentication\nand determine user capabilities.\n\n## Endpoint\n`GET /{prefix}/api/v1/users/me`\n\n## Response\nReturns a `KomgaUserDto` containing:\n- User ID (UUID as string)\n- Email address\n- Roles (ADMIN, USER, FILE_DOWNLOAD)\n- Library access settings\n- Content restrictions\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_current_user", + "summary": "List books in a series (OPDS 2.0)", + "description": "Returns a publications feed with all books in the specified series", + "operationId": "opds2_series_books", "parameters": [ { - "name": "prefix", + "name": "series_id", "in": "path", - "description": "Komga API prefix (default: komga)", + "description": "Series ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], "responses": { "200": { - "description": "Current user information", + "description": "OPDS 2.0 series books feed", "content": { - "application/json": { + "application/opds+json": { "schema": { - "$ref": "#/components/schemas/KomgaUserDto" + "$ref": "#/components/schemas/Opds2Feed" } } } }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Series not found" } }, "security": [ @@ -11482,4119 +11535,3478 @@ } ] } - } - }, - "components": { - "schemas": { - "AccessMode": { - "type": "string", - "description": "Access mode for sharing tag grants", - "enum": [ - "allow", - "deny" - ] - }, - "AddSeriesGenreRequest": { - "type": "object", - "description": "Request to add a single genre to a series", - "required": [ - "name" + }, + "/{prefix}/api/v1/books/list": { + "post": { + "tags": [ + "Komga" ], - "properties": { - "name": { - "type": "string", - "description": "Name of the genre to add\nThe genre will be created if it doesn't exist", - "example": "Action" + "summary": "Search/filter books", + "description": "Returns books matching the filter criteria.\nThis uses POST to support complex filter bodies.\n\n## Endpoint\n`POST /{prefix}/api/v1/books/list`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n- `sort` - Sort parameter (e.g., \"createdDate,desc\")\n\n## Request Body\nJSON object with filter criteria (library_id, series_id, search_term, etc.)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_search_books", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "Page number (0-indexed, Komga-style)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "size", + "in": "query", + "description": "Page size (default: 20)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "sort", + "in": "query", + "description": "Sort parameter (e.g., \"createdDate,desc\", \"metadata.numberSort,asc\")", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } } - } - }, - "AddSeriesTagRequest": { - "type": "object", - "description": "Request to add a single tag to a series", - "required": [ - "name" ], - "properties": { - "name": { - "type": "string", - "description": "Name of the tag to add\nThe tag will be created if it doesn't exist", - "example": "Favorite" - } - } - }, - "AdjacentBooksResponse": { - "type": "object", - "description": "Response containing adjacent books in the same series\n\nReturns the previous and next books relative to the requested book,\nordered by book number within the series.", - "properties": { - "next": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/BookDto", - "description": "The next book in the series (higher number), if any" + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaBooksSearchRequestDto" } - ] + } }, - "prev": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/BookDto", - "description": "The previous book in the series (lower number), if any" + "required": true + }, + "responses": { + "200": { + "description": "Paginated list of books matching filter", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaPage_KomgaBookDto" + } } - ] - } - } - }, - "AlternateTitleDto": { - "type": "object", - "description": "Alternate title data transfer object", - "required": [ - "id", - "seriesId", - "label", - "title", - "createdAt", - "updatedAt" - ], - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the title was created", - "example": "2024-01-01T00:00:00Z" + } }, - "id": { - "type": "string", - "format": "uuid", - "description": "Alternate title ID", - "example": "550e8400-e29b-41d4-a716-446655440040" + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "label": { - "type": "string", - "description": "Label for this title (e.g., \"Japanese\", \"Romaji\", \"English\", \"Korean\")", - "example": "Japanese" + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/books/ondeck": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get \"on deck\" books", + "description": "Returns books that are currently in-progress (started but not completed).\nThis is the \"continue reading\" shelf in Komic.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/ondeck`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_books_ondeck", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "seriesId": { - "type": "string", - "format": "uuid", - "description": "Series ID", - "example": "550e8400-e29b-41d4-a716-446655440002" + { + "name": "page", + "in": "query", + "description": "Page number (0-indexed, Komga-style)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } }, - "title": { - "type": "string", - "description": "The alternate title", - "example": "進撃の巨人" + { + "name": "size", + "in": "query", + "description": "Page size (default: 20)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the title was last updated", - "example": "2024-01-15T10:30:00Z" + { + "name": "sort", + "in": "query", + "description": "Sort parameter (e.g., \"createdDate,desc\", \"metadata.numberSort,asc\")", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } } - } - }, - "AlternateTitleListResponse": { - "type": "object", - "description": "Response containing a list of alternate titles", - "required": [ - "titles" ], - "properties": { - "titles": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AlternateTitleDto" - }, - "description": "List of alternate titles" + "responses": { + "200": { + "description": "Paginated list of in-progress books", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaPage_KomgaBookDto" + } + } + } + }, + "401": { + "description": "Unauthorized" } - } - }, - "AnalysisResult": { - "type": "object", - "description": "Analysis result response", - "required": [ - "booksAnalyzed", - "errors" - ], - "properties": { - "booksAnalyzed": { - "type": "integer", - "description": "Number of books successfully analyzed", - "example": 150, - "minimum": 0 + }, + "security": [ + { + "jwt_bearer": [] }, - "errors": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of errors encountered during analysis" - } - } - }, - "ApiKeyDto": { - "type": "object", - "description": "API key data transfer object", - "required": [ - "id", - "userId", - "name", - "keyPrefix", - "permissions", - "isActive", - "createdAt", - "updatedAt" - ], - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the key was created", - "example": "2024-01-01T00:00:00Z" - }, - "expiresAt": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "When the key expires (if set)", - "example": "2025-12-31T23:59:59Z" - }, - "id": { - "type": "string", - "format": "uuid", - "description": "Unique API key identifier", - "example": "550e8400-e29b-41d4-a716-446655440000" - }, - "isActive": { - "type": "boolean", - "description": "Whether the key is currently active", - "example": true - }, - "keyPrefix": { - "type": "string", - "description": "Prefix of the key for identification", - "example": "cdx_a1b2c3" - }, - "lastUsedAt": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "When the key was last used", - "example": "2024-01-15T10:30:00Z" - }, - "name": { - "type": "string", - "description": "Human-readable name for the key", - "example": "Mobile App Key" - }, - "permissions": { - "description": "Permissions granted to this key" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the key was last updated", - "example": "2024-01-15T10:30:00Z" - }, - "userId": { - "type": "string", - "format": "uuid", - "description": "Owner user ID", - "example": "550e8400-e29b-41d4-a716-446655440001" + { + "api_key": [] } - } - }, - "AppInfoDto": { - "type": "object", - "description": "Application information response", - "required": [ - "version", - "name" + ] + } + }, + "/{prefix}/api/v1/books/{book_id}": { + "get": { + "tags": [ + "Komga" ], - "properties": { - "name": { - "type": "string", - "description": "Application name", - "example": "codex" - }, - "version": { - "type": "string", - "description": "Application version from Cargo.toml", - "example": "1.0.0" - } - } - }, - "BelongsTo": { - "type": "object", - "description": "Series membership information", - "properties": { - "series": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/SeriesInfo", - "description": "Series information" - } - ] - } - } - }, - "BookCondition": { - "oneOf": [ + "summary": "Get a book by ID", + "description": "Returns a single book in Komga-compatible format.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_book", + "parameters": [ { - "type": "object", - "description": "All conditions must match (AND)", - "required": [ - "allOf" - ], - "properties": { - "allOf": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BookCondition" - } - } + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" } }, { - "type": "object", - "description": "Any condition must match (OR)", - "required": [ - "anyOf" - ], - "properties": { - "anyOf": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BookCondition" + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Book details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaBookDto" } } } }, - { - "type": "object", - "description": "Filter by library ID", - "required": [ - "libraryId" - ], - "properties": { - "libraryId": { - "$ref": "#/components/schemas/UuidOperator" - } - } + "401": { + "description": "Unauthorized" }, + "404": { + "description": "Book not found" + } + }, + "security": [ { - "type": "object", - "description": "Filter by series ID", - "required": [ - "seriesId" - ], - "properties": { - "seriesId": { - "$ref": "#/components/schemas/UuidOperator" - } - } + "jwt_bearer": [] }, { - "type": "object", - "description": "Filter by genre name (from parent series)", - "required": [ - "genre" - ], - "properties": { - "genre": { - "$ref": "#/components/schemas/FieldOperator" - } - } - }, + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/books/{book_id}/file": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Download book file", + "description": "Streams the original book file (CBZ, CBR, EPUB, PDF) for download.\nIncludes proper Content-Disposition header with UTF-8 encoding.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/file`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_download_book_file", + "parameters": [ { - "type": "object", - "description": "Filter by tag name (from parent series)", - "required": [ - "tag" - ], - "properties": { - "tag": { - "$ref": "#/components/schemas/FieldOperator" - } + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" } }, { - "type": "object", - "description": "Filter by book title", - "required": [ - "title" - ], - "properties": { - "title": { - "$ref": "#/components/schemas/FieldOperator" - } + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Book file download", + "content": { + "application/octet-stream": {} } }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Book not found or file missing" + } + }, + "security": [ { - "type": "object", - "description": "Filter by read status (unread, in_progress, read)", - "required": [ - "readStatus" - ], - "properties": { - "readStatus": { - "$ref": "#/components/schemas/FieldOperator" - } - } + "jwt_bearer": [] }, { - "type": "object", - "description": "Filter by books with analysis errors", - "required": [ - "hasError" - ], - "properties": { - "hasError": { - "$ref": "#/components/schemas/BoolOperator" - } - } + "api_key": [] } + ] + } + }, + "/{prefix}/api/v1/books/{book_id}/next": { + "get": { + "tags": [ + "Komga" ], - "description": "Book-level search conditions" - }, - "BookDetailResponse": { - "type": "object", - "description": "Detailed book response with metadata", - "required": [ - "book" - ], - "properties": { - "book": { - "$ref": "#/components/schemas/BookDto", - "description": "The book data" + "summary": "Get next book in series", + "description": "Returns the next book in the same series by sort order.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/next`\n\n## Response\n- 200: Next book DTO\n- 404: No next book (this is the last book in series)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_next_book", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "metadata": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/BookMetadataDto", - "description": "Optional metadata from ComicInfo.xml or similar" - } - ] + { + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } } - } - }, - "BookDto": { - "type": "object", - "description": "Book data transfer object", - "required": [ - "id", - "libraryId", - "libraryName", - "seriesId", - "seriesName", - "title", - "filePath", - "fileFormat", - "fileSize", - "fileHash", - "pageCount", - "createdAt", - "updatedAt", - "deleted" ], - "properties": { - "analysisError": { - "type": [ - "string", - "null" - ], - "description": "Error message if book analysis failed", - "example": "Failed to parse CBZ: invalid archive" + "responses": { + "200": { + "description": "Next book in series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaBookDto" + } + } + } }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the book was added to the library", - "example": "2024-01-01T00:00:00Z" + "401": { + "description": "Unauthorized" }, - "deleted": { - "type": "boolean", - "description": "Whether the book has been soft-deleted", - "example": false + "404": { + "description": "No next book" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "fileFormat": { - "type": "string", - "description": "File format (cbz, cbr, epub, pdf)", - "example": "cbz" + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/books/{book_id}/pages": { + "get": { + "tags": [ + "Komga" + ], + "summary": "List all pages for a book", + "description": "Returns an array of page metadata for all pages in a book.\nPages are ordered by page number (1-indexed).\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/pages`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key\n\n## Response\nReturns an array of `KomgaPageDto` objects with page metadata including\nfilename, MIME type, dimensions, and size.", + "operationId": "komga_list_pages", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "fileHash": { - "type": "string", - "description": "File hash for deduplication", - "example": "a1b2c3d4e5f6g7h8i9j0" + { + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "List of pages in the book", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KomgaPageDto" + } + } + } + } }, - "filePath": { - "type": "string", - "description": "Filesystem path to the book file", - "example": "/media/comics/Batman/Batman - Year One 001.cbz" + "401": { + "description": "Unauthorized" }, - "fileSize": { - "type": "integer", - "format": "int64", - "description": "File size in bytes", - "example": 52428800 + "404": { + "description": "Book not found" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "id": { - "type": "string", - "format": "uuid", - "description": "Book unique identifier", - "example": "550e8400-e29b-41d4-a716-446655440001" + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/books/{book_id}/pages/{page_number}": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get a specific page image", + "description": "Streams the raw page image for the requested page number.\nPage numbers are 1-indexed.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/pages/{pageNumber}`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key (via cookie fallback for browser image tags)\n\n## Response\nReturns the raw image data with appropriate Content-Type header.\nResponse is cached for 1 year (immutable content).", + "operationId": "komga_get_page", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "libraryId": { - "type": "string", - "format": "uuid", - "description": "Library this book belongs to", - "example": "550e8400-e29b-41d4-a716-446655440000" + { + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } }, - "libraryName": { - "type": "string", - "description": "Name of the library", - "example": "Comics" + { + "name": "page_number", + "in": "path", + "description": "Page number (1-indexed)", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Page image", + "content": { + "image/*": {} + } }, - "number": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Book number within the series", - "example": 1 + "401": { + "description": "Unauthorized" }, - "pageCount": { - "type": "integer", - "format": "int32", - "description": "Number of pages in the book", - "example": 32 + "404": { + "description": "Book or page not found" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "readProgress": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ReadProgressResponse", - "description": "User's read progress for this book" - } - ] + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/books/{book_id}/pages/{page_number}/thumbnail": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get a page thumbnail", + "description": "Returns a thumbnail version of the requested page.\nThumbnails are resized to max 300px width/height while maintaining aspect ratio.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/pages/{pageNumber}/thumbnail`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key (via cookie fallback for browser image tags)\n\n## Response\nReturns a JPEG thumbnail with appropriate caching headers.", + "operationId": "komga_get_page_thumbnail", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "readingDirection": { - "type": [ - "string", - "null" - ], - "description": "Effective reading direction (from series metadata, or library default if not set)\nValues: ltr, rtl, ttb or webtoon", - "example": "ltr" + { + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } }, - "seriesId": { - "type": "string", - "format": "uuid", - "description": "Series this book belongs to", - "example": "550e8400-e29b-41d4-a716-446655440002" + { + "name": "page_number", + "in": "path", + "description": "Page number (1-indexed)", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Page thumbnail image", + "content": { + "image/jpeg": {} + } }, - "seriesName": { - "type": "string", - "description": "Name of the series", - "example": "Batman: Year One" + "401": { + "description": "Unauthorized" }, - "title": { - "type": "string", - "description": "Book title", - "example": "Batman: Year One #1" + "404": { + "description": "Book or page not found" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "titleSort": { - "type": [ - "string", - "null" - ], - "description": "Title used for sorting (title_sort field)", - "example": "batman year one 001" + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/books/{book_id}/previous": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get previous book in series", + "description": "Returns the previous book in the same series by sort order.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/previous`\n\n## Response\n- 200: Previous book DTO\n- 404: No previous book (this is the first book in series)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_previous_book", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the book was last updated", - "example": "2024-01-15T10:30:00Z" + { + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } } - } - }, - "BookErrorDto": { - "type": "object", - "description": "A single error for a book", - "required": [ - "errorType", - "message", - "occurredAt" ], - "properties": { - "details": { - "description": "Additional error details (optional)" + "responses": { + "200": { + "description": "Previous book in series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaBookDto" + } + } + } }, - "errorType": { - "$ref": "#/components/schemas/BookErrorTypeDto", - "description": "Type of the error" + "401": { + "description": "Unauthorized" }, - "message": { - "type": "string", - "description": "Human-readable error message", - "example": "Failed to parse CBZ: invalid archive" + "404": { + "description": "No previous book" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "occurredAt": { - "type": "string", - "format": "date-time", - "description": "When the error occurred", - "example": "2024-01-15T10:30:00Z" + { + "api_key": [] } - } - }, - "BookErrorTypeDto": { - "type": "string", - "description": "Book error type", - "enum": [ - "format_detection", - "parser", - "metadata", - "thumbnail", - "page_extraction", - "pdf_rendering", - "other" ] - }, - "BookFullMetadata": { - "type": "object", - "description": "Full book metadata including all fields and their lock states", - "required": [ - "locks", - "createdAt", - "updatedAt" - ], - "properties": { - "blackAndWhite": { - "type": [ - "boolean", - "null" - ], - "description": "Whether the book is black and white", - "example": false - }, - "colorist": { - "type": [ - "string", - "null" - ], - "description": "Colorist(s)", - "example": "Richmond Lewis" - }, - "count": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Total count in series", - "example": 4 - }, - "coverArtist": { - "type": [ - "string", - "null" - ], - "description": "Cover artist(s)", - "example": "David Mazzucchelli" + } + }, + "/{prefix}/api/v1/books/{book_id}/read-progress": { + "delete": { + "tags": [ + "Komga" + ], + "summary": "Delete reading progress for a book (mark as unread)", + "description": "Removes all reading progress for a book, effectively marking it as unread.\n\n## Endpoint\n`DELETE /{prefix}/api/v1/books/{bookId}/read-progress`\n\n## Response\n- 204 No Content on success\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_delete_progress", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the metadata was created", - "example": "2024-01-01T00:00:00Z" + { + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Progress deleted successfully" }, - "day": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Publication day (1-31)", - "example": 1 + "401": { + "description": "Unauthorized" }, - "editor": { - "type": [ - "string", - "null" - ], - "description": "Editor(s)", - "example": "Dennis O'Neil" + "404": { + "description": "Book not found" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "formatDetail": { - "type": [ - "string", - "null" - ], - "description": "Format details", - "example": "Trade Paperback" + { + "api_key": [] + } + ] + }, + "patch": { + "tags": [ + "Komga" + ], + "summary": "Update reading progress for a book", + "description": "Updates the user's reading progress for a specific book.\nKomic sends: `{ \"completed\": false, \"page\": 151 }`\n\n## Endpoint\n`PATCH /{prefix}/api/v1/books/{bookId}/read-progress`\n\n## Request Body\n- `page` - Current page number (1-indexed, optional)\n- `completed` - Whether book is completed (optional)\n- `device_id` - Device ID (optional, not used by Komic)\n- `device_name` - Device name (optional, not used by Komic)\n\n## Response\n- 204 No Content on success (Komga behavior)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_update_progress", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "genre": { - "type": [ - "string", - "null" - ], - "description": "Genre", - "example": "Superhero" + { + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaReadProgressUpdateDto" + } + } }, - "imprint": { - "type": [ - "string", - "null" - ], - "description": "Imprint name", - "example": "DC Black Label" + "required": true + }, + "responses": { + "204": { + "description": "Progress updated successfully" }, - "inker": { - "type": [ - "string", - "null" - ], - "description": "Inker(s)", - "example": "David Mazzucchelli" + "401": { + "description": "Unauthorized" }, - "isbns": { - "type": [ - "string", - "null" - ], - "description": "ISBN(s)", - "example": "978-1401207526" + "404": { + "description": "Book not found" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "languageIso": { - "type": [ - "string", - "null" - ], - "description": "ISO language code", - "example": "en" + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/books/{book_id}/thumbnail": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get book thumbnail", + "description": "Returns a thumbnail image for the book's first page.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/thumbnail`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_book_thumbnail", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "letterer": { - "type": [ - "string", - "null" - ], - "description": "Letterer(s)", - "example": "Todd Klein" + { + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Book thumbnail image", + "content": { + "image/jpeg": {} + } }, - "locks": { - "$ref": "#/components/schemas/BookMetadataLocks", - "description": "Lock states for all metadata fields" + "401": { + "description": "Unauthorized" }, - "manga": { - "type": [ - "boolean", - "null" - ], - "description": "Whether the book is manga format", - "example": false + "404": { + "description": "Book not found or has no pages" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "month": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Publication month (1-12)", - "example": 2 - }, - "number": { - "type": [ - "string", - "null" - ], - "description": "Chapter/book number", - "example": "1" - }, - "penciller": { - "type": [ - "string", - "null" - ], - "description": "Penciller(s)", - "example": "David Mazzucchelli" + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/libraries": { + "get": { + "tags": [ + "Komga" + ], + "summary": "List all libraries", + "description": "Returns all libraries in Komga-compatible format.\n\n## Endpoint\n`GET /{prefix}/api/v1/libraries`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_list_libraries", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of libraries", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KomgaLibraryDto" + } + } + } + } }, - "publisher": { - "type": [ - "string", - "null" - ], - "description": "Publisher name", - "example": "DC Comics" + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "summary": { - "type": [ - "string", - "null" - ], - "description": "Book summary/description", - "example": "Bruce Wayne returns to Gotham City after years abroad." + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/libraries/{library_id}": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get library by ID", + "description": "Returns a single library in Komga-compatible format.\n\n## Endpoint\n`GET /{prefix}/api/v1/libraries/{libraryId}`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_library", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "title": { - "type": [ - "string", - "null" - ], - "description": "Book title from metadata", - "example": "Batman: Year One #1" + { + "name": "library_id", + "in": "path", + "description": "Library ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Library details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaLibraryDto" + } + } + } }, - "titleSort": { - "type": [ - "string", - "null" - ], - "description": "Sort title for ordering", - "example": "batman year one 001" + "401": { + "description": "Unauthorized" }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the metadata was last updated", - "example": "2024-01-15T10:30:00Z" + "404": { + "description": "Library not found" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "volume": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Volume number", - "example": 1 + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/libraries/{library_id}/thumbnail": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get library thumbnail", + "description": "Returns a thumbnail image for the library. Uses the first series' cover\nas the library thumbnail, or returns a 404 if no series exist.\n\n## Endpoint\n`GET /{prefix}/api/v1/libraries/{libraryId}/thumbnail`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key (via cookie fallback for browser image tags)", + "operationId": "komga_get_library_thumbnail", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "web": { - "type": [ - "string", - "null" - ], - "description": "Web URL", - "example": "https://dc.com/batman-year-one" + { + "name": "library_id", + "in": "path", + "description": "Library ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Library thumbnail image", + "content": { + "image/jpeg": {} + } }, - "writer": { - "type": [ - "string", - "null" - ], - "description": "Writer(s)", - "example": "Frank Miller" + "401": { + "description": "Unauthorized" }, - "year": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Publication year", - "example": 1987 + "404": { + "description": "Library not found or no series in library" } - } - }, - "BookListRequest": { - "type": "object", - "description": "Request body for POST /books/list\n\nPagination parameters (page, pageSize, sort) are passed as query parameters,\nnot in the request body. This enables proper HATEOAS links.", - "properties": { - "condition": { - "type": [ - "object", - "null" - ], - "description": "Filter condition (optional - no condition returns all)" - }, - "fullTextSearch": { - "type": [ - "string", - "null" - ], - "description": "Full-text search query (case-insensitive search on book title)" + }, + "security": [ + { + "jwt_bearer": [] }, - "includeDeleted": { - "type": "boolean", - "description": "Include soft-deleted books in results (default: false)" + { + "api_key": [] } - } - }, - "BookMetadataDto": { - "type": "object", - "description": "Book metadata DTO", - "required": [ - "id", - "bookId", - "writers", - "pencillers", - "inkers", - "colorists", - "letterers", - "coverArtists", - "editors" + ] + } + }, + "/{prefix}/api/v1/series": { + "get": { + "tags": [ + "Komga" ], - "properties": { - "bookId": { - "type": "string", - "format": "uuid", - "description": "Associated book ID", - "example": "550e8400-e29b-41d4-a716-446655440001" - }, - "colorists": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Colorists", - "example": [ - "Richmond Lewis" - ] - }, - "coverArtists": { - "type": "array", - "items": { + "summary": "List all series (paginated)", + "description": "Returns all series in Komga-compatible format with pagination.\n\n## Endpoint\n`GET /{prefix}/api/v1/series`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n- `library_id` - Optional filter by library UUID\n- `search` - Optional search query\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_list_series", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { "type": "string" - }, - "description": "Cover artists", - "example": [ - "David Mazzucchelli" - ] + } }, - "editors": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Editors", - "example": [ - "Dennis O'Neil" - ] + { + "name": "page", + "in": "query", + "description": "Page number (0-indexed, Komga-style)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } }, - "genre": { - "type": [ - "string", - "null" - ], - "description": "Genre", - "example": "Superhero" + { + "name": "size", + "in": "query", + "description": "Page size (default: 20)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } }, - "id": { - "type": "string", - "format": "uuid", - "description": "Metadata record ID", - "example": "550e8400-e29b-41d4-a716-446655440003" + { + "name": "library_id", + "in": "query", + "description": "Filter by library ID", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } }, - "imprint": { - "type": [ - "string", - "null" - ], - "description": "Imprint name", - "example": "DC Black Label" + { + "name": "search", + "in": "query", + "description": "Search query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } }, - "inkers": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Inkers", - "example": [ - "David Mazzucchelli" - ] + { + "name": "sort", + "in": "query", + "description": "Sort parameter (e.g., \"metadata.titleSort,asc\", \"createdDate,desc\")", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "Paginated list of series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaPage_KomgaSeriesDto" + } + } + } }, - "languageIso": { - "type": [ - "string", - "null" - ], - "description": "ISO language code", - "example": "en" + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "letterers": { - "type": "array", - "items": { + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/series/new": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get recently added series", + "description": "Returns series sorted by created date descending (newest first).\n\n## Endpoint\n`GET /{prefix}/api/v1/series/new`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n- `library_id` - Optional filter by library UUID\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_series_new", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { "type": "string" - }, - "description": "Letterers", - "example": [ - "Todd Klein" - ] + } }, - "number": { - "type": [ - "string", - "null" - ], - "description": "Issue/chapter number from metadata", - "example": "1" + { + "name": "page", + "in": "query", + "description": "Page number (0-indexed, Komga-style)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } }, - "pageCount": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Page count from metadata", - "example": 32 + { + "name": "size", + "in": "query", + "description": "Page size (default: 20)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } }, - "pencillers": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Pencillers (line artists)", - "example": [ - "David Mazzucchelli" - ] + { + "name": "library_id", + "in": "query", + "description": "Filter by library ID", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } }, - "publisher": { - "type": [ - "string", - "null" - ], - "description": "Publisher name", - "example": "DC Comics" - }, - "releaseDate": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "Release/publication date", - "example": "1987-02-01T00:00:00Z" - }, - "series": { - "type": [ - "string", - "null" - ], - "description": "Series name from metadata", - "example": "Batman: Year One" - }, - "summary": { - "type": [ - "string", - "null" - ], - "description": "Book summary/description", - "example": "Bruce Wayne returns to Gotham City after years abroad to begin his war on crime." - }, - "title": { - "type": [ - "string", - "null" - ], - "description": "Book title from metadata", - "example": "Batman: Year One #1" + { + "name": "search", + "in": "query", + "description": "Search query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } }, - "writers": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Writers/authors", - "example": [ - "Frank Miller" - ] + { + "name": "sort", + "in": "query", + "description": "Sort parameter (e.g., \"metadata.titleSort,asc\", \"createdDate,desc\")", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } } - } - }, - "BookMetadataLocks": { - "type": "object", - "description": "Book metadata lock states\n\nIndicates which metadata fields are locked (protected from automatic updates).\nWhen a field is locked, the scanner will not overwrite user-edited values.", - "required": [ - "summaryLock", - "writerLock", - "pencillerLock", - "inkerLock", - "coloristLock", - "lettererLock", - "coverArtistLock", - "editorLock", - "publisherLock", - "imprintLock", - "genreLock", - "webLock", - "languageIsoLock", - "formatDetailLock", - "blackAndWhiteLock", - "mangaLock", - "yearLock", - "monthLock", - "dayLock", - "volumeLock", - "countLock", - "isbnsLock" ], - "properties": { - "blackAndWhiteLock": { - "type": "boolean", - "description": "Whether black_and_white is locked", - "example": false - }, - "coloristLock": { - "type": "boolean", - "description": "Whether colorist is locked", - "example": false + "responses": { + "200": { + "description": "Paginated list of recently added series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaPage_KomgaSeriesDto" + } + } + } }, - "countLock": { - "type": "boolean", - "description": "Whether count is locked", - "example": false + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "coverArtistLock": { - "type": "boolean", - "description": "Whether cover artist is locked", - "example": false + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/series/updated": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get recently updated series", + "description": "Returns series sorted by last modified date descending (most recently updated first).\n\n## Endpoint\n`GET /{prefix}/api/v1/series/updated`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n- `library_id` - Optional filter by library UUID\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_series_updated", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "dayLock": { - "type": "boolean", - "description": "Whether day is locked", - "example": false + { + "name": "page", + "in": "query", + "description": "Page number (0-indexed, Komga-style)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } }, - "editorLock": { - "type": "boolean", - "description": "Whether editor is locked", - "example": false + { + "name": "size", + "in": "query", + "description": "Page size (default: 20)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } }, - "formatDetailLock": { - "type": "boolean", - "description": "Whether format_detail is locked", - "example": false + { + "name": "library_id", + "in": "query", + "description": "Filter by library ID", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } }, - "genreLock": { - "type": "boolean", - "description": "Whether genre is locked", - "example": false + { + "name": "search", + "in": "query", + "description": "Search query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } }, - "imprintLock": { - "type": "boolean", - "description": "Whether imprint is locked", - "example": false + { + "name": "sort", + "in": "query", + "description": "Sort parameter (e.g., \"metadata.titleSort,asc\", \"createdDate,desc\")", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "Paginated list of recently updated series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaPage_KomgaSeriesDto" + } + } + } }, - "inkerLock": { - "type": "boolean", - "description": "Whether inker is locked", - "example": false + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "isbnsLock": { - "type": "boolean", - "description": "Whether isbns is locked", - "example": false - }, - "languageIsoLock": { - "type": "boolean", - "description": "Whether language_iso is locked", - "example": false + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/series/{series_id}": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get series by ID", + "description": "Returns a single series in Komga-compatible format.\n\n## Endpoint\n`GET /{prefix}/api/v1/series/{seriesId}`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_series", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "lettererLock": { - "type": "boolean", - "description": "Whether letterer is locked", - "example": false + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Series details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaSeriesDto" + } + } + } }, - "mangaLock": { - "type": "boolean", - "description": "Whether manga is locked", - "example": false + "401": { + "description": "Unauthorized" }, - "monthLock": { - "type": "boolean", - "description": "Whether month is locked", - "example": false + "404": { + "description": "Series not found" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "pencillerLock": { - "type": "boolean", - "description": "Whether penciller is locked", - "example": false + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/series/{series_id}/books": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get books in a series", + "description": "Returns all books in a series with pagination.\n\n## Endpoint\n`GET /{prefix}/api/v1/series/{seriesId}/books`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_series_books", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "publisherLock": { - "type": "boolean", - "description": "Whether publisher is locked", - "example": true + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } }, - "summaryLock": { - "type": "boolean", - "description": "Whether summary is locked", - "example": false + { + "name": "page", + "in": "query", + "description": "Page number (0-indexed, Komga-style)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } }, - "volumeLock": { - "type": "boolean", - "description": "Whether volume is locked", - "example": false + { + "name": "size", + "in": "query", + "description": "Page size (default: 20)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } }, - "webLock": { - "type": "boolean", - "description": "Whether web URL is locked", - "example": false + { + "name": "library_id", + "in": "query", + "description": "Filter by library ID", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } }, - "writerLock": { - "type": "boolean", - "description": "Whether writer is locked", - "example": false + { + "name": "search", + "in": "query", + "description": "Search query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } }, - "yearLock": { - "type": "boolean", - "description": "Whether year is locked", - "example": true + { + "name": "sort", + "in": "query", + "description": "Sort parameter (e.g., \"metadata.titleSort,asc\", \"createdDate,desc\")", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } } - } - }, - "BookMetadataResponse": { - "type": "object", - "description": "Response containing book metadata", - "required": [ - "bookId", - "updatedAt" ], - "properties": { - "blackAndWhite": { - "type": [ - "boolean", - "null" - ], - "description": "Whether the book is black and white", - "example": false - }, - "bookId": { - "type": "string", - "format": "uuid", - "description": "Book ID", - "example": "550e8400-e29b-41d4-a716-446655440001" - }, - "colorist": { - "type": [ - "string", - "null" - ], - "description": "Colorist(s)", - "example": "Richmond Lewis" - }, - "count": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Total count in series", - "example": 4 + "responses": { + "200": { + "description": "Paginated list of books in series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaPage_KomgaBookDto" + } + } + } }, - "coverArtist": { - "type": [ - "string", - "null" - ], - "description": "Cover artist(s)", - "example": "David Mazzucchelli" + "401": { + "description": "Unauthorized" }, - "day": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Publication day (1-31)", - "example": 1 + "404": { + "description": "Series not found" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "editor": { - "type": [ - "string", - "null" - ], - "description": "Editor(s)", - "example": "Dennis O'Neil" - }, - "formatDetail": { - "type": [ - "string", - "null" - ], - "description": "Format details", - "example": "Trade Paperback" - }, - "genre": { - "type": [ - "string", - "null" - ], - "description": "Genre", - "example": "Superhero" - }, - "imprint": { - "type": [ - "string", - "null" - ], - "description": "Imprint name", - "example": "DC Black Label" - }, - "inker": { - "type": [ - "string", - "null" - ], - "description": "Inker(s)", - "example": "David Mazzucchelli" - }, - "isbns": { - "type": [ - "string", - "null" - ], - "description": "ISBN(s)", - "example": "978-1401207526" - }, - "languageIso": { - "type": [ - "string", - "null" - ], - "description": "ISO language code", - "example": "en" - }, - "letterer": { - "type": [ - "string", - "null" - ], - "description": "Letterer(s)", - "example": "Todd Klein" - }, - "manga": { - "type": [ - "boolean", - "null" - ], - "description": "Whether the book is manga format", - "example": false - }, - "month": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Publication month (1-12)", - "example": 2 - }, - "penciller": { - "type": [ - "string", - "null" - ], - "description": "Penciller(s)", - "example": "David Mazzucchelli" - }, - "publisher": { - "type": [ - "string", - "null" - ], - "description": "Publisher name", - "example": "DC Comics" + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/series/{series_id}/thumbnail": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get series thumbnail", + "description": "Returns a thumbnail image for the series.\n\n## Endpoint\n`GET /{prefix}/api/v1/series/{seriesId}/thumbnail`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_series_thumbnail", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "summary": { - "type": [ - "string", - "null" - ], - "description": "Book summary/description", - "example": "Bruce Wayne returns to Gotham City." + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Series thumbnail image", + "content": { + "image/jpeg": {} + } }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "Last update timestamp", - "example": "2024-01-15T10:30:00Z" + "401": { + "description": "Unauthorized" }, - "volume": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Volume number", - "example": 1 + "404": { + "description": "Series not found" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "web": { - "type": [ - "string", - "null" - ], - "description": "Web URL", - "example": "https://dc.com/batman-year-one" + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/users/me": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get current user information", + "description": "Returns information about the currently authenticated user in Komga format.\nThis endpoint is used by Komic and other apps to verify authentication\nand determine user capabilities.\n\n## Endpoint\n`GET /{prefix}/api/v1/users/me`\n\n## Response\nReturns a `KomgaUserDto` containing:\n- User ID (UUID as string)\n- Email address\n- Roles (ADMIN, USER, FILE_DOWNLOAD)\n- Library access settings\n- Content restrictions\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_current_user", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Current user information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaUserDto" + } + } + } }, - "writer": { - "type": [ - "string", - "null" - ], - "description": "Writer(s)", - "example": "Frank Miller" + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "year": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Publication year", - "example": 1987 + { + "api_key": [] } - } - }, - "BookStrategy": { + ] + } + } + }, + "components": { + "schemas": { + "AccessMode": { "type": "string", - "description": "Book naming strategy type for determining book titles\n\nDetermines how individual book titles and numbers are resolved.", + "description": "Access mode for sharing tag grants", "enum": [ - "filename", - "metadata_first", - "smart", - "series_name", - "custom" + "allow", + "deny" ] }, - "BookWithErrorsDto": { + "AddSeriesGenreRequest": { "type": "object", - "description": "A book with its associated errors", + "description": "Request to add a single genre to a series", "required": [ - "book", - "errors" + "name" ], "properties": { - "book": { - "$ref": "#/components/schemas/BookDto", - "description": "The book data" - }, - "errors": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BookErrorDto" - }, - "description": "All errors for this book" + "name": { + "type": "string", + "description": "Name of the genre to add\nThe genre will be created if it doesn't exist", + "example": "Action" } } }, - "BooksPaginationQuery": { + "AddSeriesTagRequest": { "type": "object", - "description": "Query parameters for paginated book endpoints", + "description": "Request to add a single tag to a series", + "required": [ + "name" + ], "properties": { - "page": { - "type": "integer", - "format": "int32", - "description": "Page number (0-indexed, Komga-style)" - }, - "size": { - "type": "integer", - "format": "int32", - "description": "Page size (default: 20)" + "name": { + "type": "string", + "description": "Name of the tag to add\nThe tag will be created if it doesn't exist", + "example": "Favorite" + } + } + }, + "AdjacentBooksResponse": { + "type": "object", + "description": "Response containing adjacent books in the same series\n\nReturns the previous and next books relative to the requested book,\nordered by book number within the series.", + "properties": { + "next": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/BookDto", + "description": "The next book in the series (higher number), if any" + } + ] }, - "sort": { - "type": [ - "string", - "null" - ], - "description": "Sort parameter (e.g., \"createdDate,desc\", \"metadata.numberSort,asc\")" + "prev": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/BookDto", + "description": "The previous book in the series (lower number), if any" + } + ] } } }, - "BooksWithErrorsResponse": { + "AlphabeticalGroupDto": { "type": "object", - "description": "Response for listing books with errors", + "description": "Alphabetical group with count\n\nRepresents a group of series starting with a specific letter/character\nalong with the count of series in that group.", "required": [ - "totalBooksWithErrors", - "errorCounts", - "groups", - "page", - "pageSize", - "totalPages" + "group", + "count" ], "properties": { - "errorCounts": { - "type": "object", - "description": "Count of books by error type", - "additionalProperties": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "propertyNames": { - "type": "string" - }, - "example": { - "parser": 5, - "thumbnail": 10 - } - }, - "groups": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ErrorGroupDto" - }, - "description": "Error groups with books" - }, - "page": { - "type": "integer", - "format": "int64", - "description": "Current page (0-indexed)", - "example": 0, - "minimum": 0 - }, - "pageSize": { - "type": "integer", - "format": "int64", - "description": "Page size", - "example": 20, - "minimum": 0 - }, - "totalBooksWithErrors": { + "count": { "type": "integer", "format": "int64", - "description": "Total number of books with errors", - "example": 15, - "minimum": 0 + "description": "Number of series starting with this character", + "example": 20 }, - "totalPages": { - "type": "integer", - "format": "int64", - "description": "Total number of pages", - "example": 1, - "minimum": 0 + "group": { + "type": "string", + "description": "The first character (lowercase letter, digit, or special character)", + "example": "a" } } }, - "BoolOperator": { - "oneOf": [ - { - "type": "object", - "required": [ - "operator" - ], - "properties": { - "operator": { - "type": "string", - "enum": [ - "isTrue" - ] - } - } - }, - { - "type": "object", - "required": [ - "operator" - ], - "properties": { - "operator": { - "type": "string", - "enum": [ - "isFalse" - ] - } - } - } - ], - "description": "Operators for boolean comparisons" - }, - "BrandingSettingsDto": { + "AlternateTitleDto": { "type": "object", - "description": "Branding settings DTO (unauthenticated access)\n\nContains branding-related settings that can be accessed without authentication.\nUsed on the login page and other unauthenticated UI surfaces.", + "description": "Alternate title data transfer object", "required": [ - "application_name" + "id", + "seriesId", + "label", + "title", + "createdAt", + "updatedAt" ], "properties": { - "application_name": { + "createdAt": { "type": "string", - "description": "The application name to display", - "example": "Codex" + "format": "date-time", + "description": "When the title was created", + "example": "2024-01-01T00:00:00Z" + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Alternate title ID", + "example": "550e8400-e29b-41d4-a716-446655440040" + }, + "label": { + "type": "string", + "description": "Label for this title (e.g., \"Japanese\", \"Romaji\", \"English\", \"Korean\")", + "example": "Japanese" + }, + "seriesId": { + "type": "string", + "format": "uuid", + "description": "Series ID", + "example": "550e8400-e29b-41d4-a716-446655440002" + }, + "title": { + "type": "string", + "description": "The alternate title", + "example": "進撃の巨人" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the title was last updated", + "example": "2024-01-15T10:30:00Z" } } }, - "BrowseResponse": { + "AlternateTitleListResponse": { "type": "object", + "description": "Response containing a list of alternate titles", "required": [ - "current_path", - "entries" + "titles" ], "properties": { - "current_path": { - "type": "string", - "description": "Current directory path" - }, - "entries": { + "titles": { "type": "array", "items": { - "$ref": "#/components/schemas/FileSystemEntry" + "$ref": "#/components/schemas/AlternateTitleDto" }, - "description": "List of entries in the current directory" - }, - "parent_path": { - "type": [ - "string", - "null" - ], - "description": "Parent directory path (None if at root)" + "description": "List of alternate titles" } - }, - "example": { - "current_path": "/home/user/Documents", - "entries": [ - { - "is_directory": true, - "is_readable": true, - "name": "Comics", - "path": "/home/user/Documents/Comics" - }, - { - "is_directory": true, - "is_readable": true, - "name": "Manga", - "path": "/home/user/Documents/Manga" - } - ], - "parent_path": "/home/user" } }, - "BulkSetPreferencesRequest": { + "AnalysisResult": { "type": "object", - "description": "Request to set multiple preferences at once", + "description": "Analysis result response", "required": [ - "preferences" + "booksAnalyzed", + "errors" ], "properties": { - "preferences": { - "type": "object", - "description": "Map of preference keys to values", - "additionalProperties": {}, - "propertyNames": { + "booksAnalyzed": { + "type": "integer", + "description": "Number of books successfully analyzed", + "example": 150, + "minimum": 0 + }, + "errors": { + "type": "array", + "items": { "type": "string" }, - "example": { - "reader.zoom": 150, - "ui.theme": "dark" - } + "description": "List of errors encountered during analysis" } } }, - "BulkSettingUpdate": { + "ApiKeyDto": { "type": "object", - "description": "Single setting update in a bulk operation", + "description": "API key data transfer object", "required": [ - "key", - "value" + "id", + "userId", + "name", + "keyPrefix", + "permissions", + "isActive", + "createdAt", + "updatedAt" ], "properties": { - "key": { + "createdAt": { "type": "string", - "description": "Setting key to update", - "example": "scan.concurrent_jobs" + "format": "date-time", + "description": "When the key was created", + "example": "2024-01-01T00:00:00Z" }, - "value": { - "type": "string", - "description": "New value for the setting", - "example": "4" - } - } - }, - "BulkUpdateSettingsRequest": { - "type": "object", - "description": "Bulk update settings request", - "required": [ - "updates" - ], - "properties": { - "change_reason": { + "expiresAt": { "type": [ "string", "null" ], - "description": "Optional reason for the changes (for audit log)", - "example": "Batch configuration update for production" - }, - "updates": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BulkSettingUpdate" - }, - "description": "List of settings to update" - } - } - }, - "CalibreSeriesMode": { - "type": "string", - "description": "How Calibre strategy groups books into series", - "enum": [ - "standalone", - "by_author", - "from_metadata" - ] - }, - "CalibreStrategyConfig": { - "type": "object", - "description": "Configuration for Calibre strategy", - "properties": { - "authorFromFolder": { - "type": "boolean", - "description": "Use author folder name as author metadata" - }, - "readOpfMetadata": { - "type": "boolean", - "description": "Read metadata.opf files for rich metadata" + "format": "date-time", + "description": "When the key expires (if set)", + "example": "2025-12-31T23:59:59Z" }, - "seriesMode": { - "$ref": "#/components/schemas/CalibreSeriesMode", - "description": "How to group books into series" + "id": { + "type": "string", + "format": "uuid", + "description": "Unique API key identifier", + "example": "550e8400-e29b-41d4-a716-446655440000" }, - "stripIdSuffix": { + "isActive": { "type": "boolean", - "description": "Strip Calibre ID suffix from folder names (e.g., \" (123)\")" - } - } - }, - "CleanupResultDto": { - "type": "object", - "description": "Result of a cleanup operation", - "required": [ - "thumbnails_deleted", - "covers_deleted", - "bytes_freed", - "failures" - ], - "properties": { - "bytes_freed": { - "type": "integer", - "format": "int64", - "description": "Total bytes freed by deletion", - "example": 1073741824, - "minimum": 0 + "description": "Whether the key is currently active", + "example": true }, - "covers_deleted": { - "type": "integer", - "format": "int32", - "description": "Number of cover files deleted", - "example": 5, - "minimum": 0 + "keyPrefix": { + "type": "string", + "description": "Prefix of the key for identification", + "example": "cdx_a1b2c3" }, - "errors": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Error messages for any failed deletions" + "lastUsedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the key was last used", + "example": "2024-01-15T10:30:00Z" }, - "failures": { - "type": "integer", - "format": "int32", - "description": "Number of files that failed to delete", - "example": 0, - "minimum": 0 + "name": { + "type": "string", + "description": "Human-readable name for the key", + "example": "Mobile App Key" }, - "thumbnails_deleted": { - "type": "integer", - "format": "int32", - "description": "Number of thumbnail files deleted", - "example": 42, - "minimum": 0 - } - } - }, - "ConfigureSettingsRequest": { - "type": "object", - "description": "Configure initial settings request", - "required": [ - "settings", - "skipConfiguration" - ], - "properties": { - "settings": { - "type": "object", - "description": "Settings to configure (key-value pairs)", - "additionalProperties": { - "type": "string" - }, - "propertyNames": { - "type": "string" - } + "permissions": { + "description": "Permissions granted to this key" }, - "skipConfiguration": { - "type": "boolean", - "description": "Whether to skip settings configuration" - } - } - }, - "ConfigureSettingsResponse": { - "type": "object", - "description": "Configure settings response", - "required": [ - "message", - "settingsConfigured" - ], - "properties": { - "message": { + "updatedAt": { "type": "string", - "description": "Success message" + "format": "date-time", + "description": "When the key was last updated", + "example": "2024-01-15T10:30:00Z" }, - "settingsConfigured": { - "type": "integer", - "description": "Number of settings configured", - "minimum": 0 + "userId": { + "type": "string", + "format": "uuid", + "description": "Owner user ID", + "example": "550e8400-e29b-41d4-a716-446655440001" } } }, - "Contributor": { + "AppInfoDto": { "type": "object", - "description": "Contributor information (author, artist, etc.)", + "description": "Application information response", "required": [ + "version", "name" ], "properties": { "name": { "type": "string", - "description": "Name of the contributor" + "description": "Application name", + "example": "codex" }, - "sortAs": { - "type": [ - "string", - "null" - ], - "description": "Sort-friendly version of the name" + "version": { + "type": "string", + "description": "Application version from Cargo.toml", + "example": "1.0.0" } } }, - "CreateAlternateTitleRequest": { + "BelongsTo": { "type": "object", - "description": "Request to create an alternate title for a series", - "required": [ - "label", - "title" - ], + "description": "Series membership information", "properties": { - "label": { - "type": "string", - "description": "Label for this title (e.g., \"Japanese\", \"Romaji\", \"English\")", - "example": "Japanese" - }, - "title": { - "type": "string", - "description": "The alternate title", - "example": "進撃の巨人" + "series": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SeriesInfo", + "description": "Series information" + } + ] } } }, - "CreateApiKeyRequest": { - "type": "object", - "description": "Create API key request", - "required": [ - "name" - ], - "properties": { - "expiresAt": { - "type": [ - "string", - "null" + "BookCondition": { + "oneOf": [ + { + "type": "object", + "description": "All conditions must match (AND)", + "required": [ + "allOf" ], - "format": "date-time", - "description": "Optional expiration date", - "example": "2025-12-31T23:59:59Z" - }, - "name": { - "type": "string", - "description": "Name/description for the API key", - "example": "Mobile App Key" + "properties": { + "allOf": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookCondition" + } + } + } }, - "permissions": { - "type": [ - "array", - "null" + { + "type": "object", + "description": "Any condition must match (OR)", + "required": [ + "anyOf" ], - "items": { - "type": "string" - }, - "description": "Permissions for the API key (array of permission strings)\nIf not provided, uses the user's current permissions" - } - } - }, - "CreateApiKeyResponse": { - "allOf": [ + "properties": { + "anyOf": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookCondition" + } + } + } + }, { - "$ref": "#/components/schemas/ApiKeyDto" + "type": "object", + "description": "Filter by library ID", + "required": [ + "libraryId" + ], + "properties": { + "libraryId": { + "$ref": "#/components/schemas/UuidOperator" + } + } }, { "type": "object", + "description": "Filter by series ID", "required": [ - "key" + "seriesId" ], "properties": { - "key": { - "type": "string", - "description": "The plaintext API key (only shown once on creation)", - "example": "cdx_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" + "seriesId": { + "$ref": "#/components/schemas/UuidOperator" } } - } - ], - "description": "API key creation response (includes plaintext key only on creation)" - }, - "CreateExternalLinkRequest": { - "type": "object", - "description": "Request to create or update an external link for a series", - "required": [ - "sourceName", - "url" - ], - "properties": { - "externalId": { - "type": [ - "string", - "null" + }, + { + "type": "object", + "description": "Filter by genre name (from parent series)", + "required": [ + "genre" ], - "description": "ID on the external source (if available)", - "example": "12345" + "properties": { + "genre": { + "$ref": "#/components/schemas/FieldOperator" + } + } }, - "sourceName": { - "type": "string", - "description": "Source name (e.g., \"myanimelist\", \"anilist\", \"mangadex\")\nWill be normalized to lowercase", - "example": "myanimelist" + { + "type": "object", + "description": "Filter by tag name (from parent series)", + "required": [ + "tag" + ], + "properties": { + "tag": { + "$ref": "#/components/schemas/FieldOperator" + } + } }, - "url": { - "type": "string", - "description": "URL to the external source", - "example": "https://myanimelist.net/manga/12345" - } - } - }, - "CreateExternalRatingRequest": { - "type": "object", - "description": "Request to create or update an external rating for a series", - "required": [ - "sourceName", - "rating" - ], - "properties": { - "rating": { - "type": "number", - "format": "double", - "description": "Rating value (0-100)", - "example": 85.5 + { + "type": "object", + "description": "Filter by book title", + "required": [ + "title" + ], + "properties": { + "title": { + "$ref": "#/components/schemas/FieldOperator" + } + } }, - "sourceName": { - "type": "string", - "description": "Source name (e.g., \"myanimelist\", \"anilist\", \"mangabaka\")\nWill be normalized to lowercase", - "example": "myanimelist" + { + "type": "object", + "description": "Filter by read status (unread, in_progress, read)", + "required": [ + "readStatus" + ], + "properties": { + "readStatus": { + "$ref": "#/components/schemas/FieldOperator" + } + } }, - "voteCount": { - "type": [ - "integer", - "null" + { + "type": "object", + "description": "Filter by books with analysis errors", + "required": [ + "hasError" ], - "format": "int32", - "description": "Number of votes (if available)", - "example": 12500 + "properties": { + "hasError": { + "$ref": "#/components/schemas/BoolOperator" + } + } } - } + ], + "description": "Book-level search conditions" }, - "CreateLibraryRequest": { + "BookDetailResponse": { "type": "object", - "description": "Create library request", + "description": "Detailed book response with metadata", "required": [ - "name", - "path" + "book" ], "properties": { - "allowedFormats": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - }, - "description": "Allowed file formats (e.g., [\"CBZ\", \"CBR\", \"EPUB\"])", - "example": [ - "CBZ", - "CBR", - "EPUB" - ] - }, - "bookConfig": { - "description": "Book strategy-specific configuration (JSON, mutable after creation)" + "book": { + "$ref": "#/components/schemas/BookDto", + "description": "The book data" }, - "bookStrategy": { + "metadata": { "oneOf": [ { "type": "null" }, { - "$ref": "#/components/schemas/BookStrategy", - "description": "Book naming strategy (mutable after creation)\nOptions: filename, metadata_first, smart, series_name" + "$ref": "#/components/schemas/BookMetadataDto", + "description": "Optional metadata from ComicInfo.xml or similar" } ] - }, - "defaultReadingDirection": { + } + } + }, + "BookDto": { + "type": "object", + "description": "Book data transfer object", + "required": [ + "id", + "libraryId", + "libraryName", + "seriesId", + "seriesName", + "title", + "filePath", + "fileFormat", + "fileSize", + "fileHash", + "pageCount", + "createdAt", + "updatedAt", + "deleted" + ], + "properties": { + "analysisError": { "type": [ "string", "null" ], - "description": "Default reading direction for books in this library (ltr, rtl, ttb or webtoon)", - "example": "ltr" + "description": "Error message if book analysis failed", + "example": "Failed to parse CBZ: invalid archive" }, - "description": { - "type": [ - "string", - "null" - ], - "description": "Optional description", - "example": "My comic book collection" + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the book was added to the library", + "example": "2024-01-01T00:00:00Z" }, - "excludedPatterns": { - "type": [ - "string", - "null" - ], - "description": "Excluded path patterns (newline-separated, e.g., \".DS_Store\\nThumbs.db\")", - "example": ".DS_Store\nThumbs.db" + "deleted": { + "type": "boolean", + "description": "Whether the book has been soft-deleted", + "example": false }, - "name": { + "fileFormat": { "type": "string", - "description": "Library name", - "example": "Comics" + "description": "File format (cbz, cbr, epub, pdf)", + "example": "cbz" }, - "numberConfig": { - "description": "Number strategy-specific configuration (JSON, mutable after creation)" + "fileHash": { + "type": "string", + "description": "File hash for deduplication", + "example": "a1b2c3d4e5f6g7h8i9j0" }, - "numberStrategy": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NumberStrategy", - "description": "Book number strategy (mutable after creation)\nOptions: file_order, metadata, filename, smart" - } - ] + "filePath": { + "type": "string", + "description": "Filesystem path to the book file", + "example": "/media/comics/Batman/Batman - Year One 001.cbz" }, - "path": { + "fileSize": { + "type": "integer", + "format": "int64", + "description": "File size in bytes", + "example": 52428800 + }, + "id": { "type": "string", - "description": "Filesystem path to the library", - "example": "/media/comics" + "format": "uuid", + "description": "Book unique identifier", + "example": "550e8400-e29b-41d4-a716-446655440001" }, - "scanImmediately": { - "type": "boolean", - "description": "Scan immediately after creation (not stored in DB)", - "example": true + "libraryId": { + "type": "string", + "format": "uuid", + "description": "Library this book belongs to", + "example": "550e8400-e29b-41d4-a716-446655440000" }, - "scanningConfig": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ScanningConfigDto", - "description": "Scanning configuration" - } - ] + "libraryName": { + "type": "string", + "description": "Name of the library", + "example": "Comics" }, - "seriesConfig": { - "description": "Strategy-specific configuration (JSON, immutable after creation)" + "number": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Book number within the series", + "example": 1 }, - "seriesStrategy": { + "pageCount": { + "type": "integer", + "format": "int32", + "description": "Number of pages in the book", + "example": 32 + }, + "readProgress": { "oneOf": [ { "type": "null" }, { - "$ref": "#/components/schemas/SeriesStrategy", - "description": "Series detection strategy (immutable after creation)\nOptions: series_volume, series_volume_chapter, flat, publisher_hierarchy, calibre, custom" + "$ref": "#/components/schemas/ReadProgressResponse", + "description": "User's read progress for this book" } ] - } - } - }, - "CreateSharingTagRequest": { - "type": "object", - "description": "Create sharing tag request", - "required": [ - "name" - ], - "properties": { - "description": { + }, + "readingDirection": { "type": [ "string", "null" ], - "description": "Optional description", - "example": "Content appropriate for children" + "description": "Effective reading direction (from series metadata, or library default if not set)\nValues: ltr, rtl, ttb or webtoon", + "example": "ltr" }, - "name": { + "seriesId": { "type": "string", - "description": "Display name for the sharing tag (must be unique)", - "example": "Kids Content" - } - } - }, - "CreateTaskRequest": { - "type": "object", - "required": [ - "task_type" - ], - "properties": { - "priority": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Priority level (higher = more urgent)", - "example": 0 + "format": "uuid", + "description": "Series this book belongs to", + "example": "550e8400-e29b-41d4-a716-446655440002" }, - "scheduled_for": { + "seriesName": { + "type": "string", + "description": "Name of the series", + "example": "Batman: Year One" + }, + "title": { + "type": "string", + "description": "Book title", + "example": "Batman: Year One #1" + }, + "titleSort": { "type": [ "string", "null" ], - "format": "date-time", - "description": "When to run the task (defaults to now)", - "example": "2024-01-15T12:00:00Z" + "description": "Title used for sorting (title_sort field)", + "example": "batman year one 001" }, - "task_type": { - "$ref": "#/components/schemas/TaskType", - "description": "Type of task to create" - } - } - }, - "CreateTaskResponse": { - "type": "object", - "required": [ - "task_id" - ], - "properties": { - "task_id": { + "updatedAt": { "type": "string", - "format": "uuid", - "description": "ID of the created task", - "example": "550e8400-e29b-41d4-a716-446655440000" + "format": "date-time", + "description": "When the book was last updated", + "example": "2024-01-15T10:30:00Z" } } }, - "CreateUserRequest": { + "BookErrorDto": { "type": "object", - "description": "Create user request", + "description": "A single error for a book", "required": [ - "username", - "email", - "password" + "errorType", + "message", + "occurredAt" ], "properties": { - "email": { - "type": "string", - "description": "Email address for the new account", - "example": "newuser@example.com" + "details": { + "description": "Additional error details (optional)" }, - "password": { - "type": "string", - "description": "Password for the new account", - "example": "securePassword123!" + "errorType": { + "$ref": "#/components/schemas/BookErrorTypeDto", + "description": "Type of the error" }, - "role": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/UserRole", - "description": "User role (reader, maintainer, admin). Defaults to reader if not specified." - } - ] + "message": { + "type": "string", + "description": "Human-readable error message", + "example": "Failed to parse CBZ: invalid archive" }, - "username": { + "occurredAt": { "type": "string", - "description": "Username for the new account", - "example": "newuser" + "format": "date-time", + "description": "When the error occurred", + "example": "2024-01-15T10:30:00Z" } } }, - "CustomStrategyConfig": { + "BookErrorTypeDto": { + "type": "string", + "description": "Book error type", + "enum": [ + "format_detection", + "parser", + "metadata", + "thumbnail", + "page_extraction", + "pdf_rendering", + "other" + ] + }, + "BookFullMetadata": { "type": "object", - "description": "Configuration for custom series strategy\n\nNote: Volume/chapter extraction from filenames is handled by the book strategy,\nnot the series strategy. Use CustomBookConfig for regex-based volume/chapter parsing.", + "description": "Full book metadata including all fields and their lock states", "required": [ - "pattern" + "locks", + "createdAt", + "updatedAt" ], "properties": { - "pattern": { - "type": "string", - "description": "Regex pattern with named capture groups for series detection\nSupported groups: publisher, series, book\nExample: \"^(?P[^/]+)/(?P[^/]+)/(?P.+)\\\\.(cbz|cbr|epub|pdf)$\"" - }, - "seriesNameTemplate": { - "type": "string", - "description": "Template for constructing series name from capture groups\nExample: \"{publisher} - {series}\"" - } - } - }, - "DeletePreferenceResponse": { - "type": "object", - "description": "Response after deleting a preference", - "required": [ - "deleted", - "message" - ], - "properties": { - "deleted": { - "type": "boolean", - "description": "Whether a preference was deleted", - "example": true + "blackAndWhite": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the book is black and white", + "example": false }, - "message": { - "type": "string", - "description": "Message describing the result", - "example": "Preference 'ui.theme' was reset to default" - } - } - }, - "DetectedSeriesDto": { - "type": "object", - "description": "Detected series information for preview", - "required": [ - "name", - "bookCount", - "sampleBooks" - ], - "properties": { - "bookCount": { - "type": "integer", - "description": "Number of books detected", - "minimum": 0 + "colorist": { + "type": [ + "string", + "null" + ], + "description": "Colorist(s)", + "example": "Richmond Lewis" }, - "metadata": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/DetectedSeriesMetadataDto", - "description": "Metadata extracted during detection" - } - ] + "count": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Total count in series", + "example": 4 }, - "name": { + "coverArtist": { + "type": [ + "string", + "null" + ], + "description": "Cover artist(s)", + "example": "David Mazzucchelli" + }, + "createdAt": { "type": "string", - "description": "Series name as detected" + "format": "date-time", + "description": "When the metadata was created", + "example": "2024-01-01T00:00:00Z" }, - "path": { + "day": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Publication day (1-31)", + "example": 1 + }, + "editor": { "type": [ "string", "null" ], - "description": "Path relative to library root" + "description": "Editor(s)", + "example": "Dennis O'Neil" }, - "sampleBooks": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Sample book filenames (first 5)" - } - } - }, - "DetectedSeriesMetadataDto": { - "type": "object", - "description": "Metadata extracted during series detection", - "properties": { - "author": { + "formatDetail": { "type": [ "string", "null" ], - "description": "Author (for calibre strategy)" + "description": "Format details", + "example": "Trade Paperback" }, - "publisher": { + "genre": { "type": [ "string", "null" ], - "description": "Publisher (for publisher_hierarchy strategy)" - } - } - }, - "DuplicateGroup": { - "type": "object", - "description": "A group of duplicate books", - "required": [ - "id", - "file_hash", - "book_ids", - "duplicate_count", - "created_at", - "updated_at" - ], - "properties": { - "book_ids": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - }, - "description": "List of book IDs that share this hash" + "description": "Genre", + "example": "Superhero" }, - "created_at": { - "type": "string", - "description": "When the duplicate was first detected", - "example": "2024-01-15T10:30:00Z" + "imprint": { + "type": [ + "string", + "null" + ], + "description": "Imprint name", + "example": "DC Black Label" }, - "duplicate_count": { - "type": "integer", - "format": "int32", - "description": "Number of duplicate copies found", - "example": 3 + "inker": { + "type": [ + "string", + "null" + ], + "description": "Inker(s)", + "example": "David Mazzucchelli" }, - "file_hash": { - "type": "string", - "description": "SHA-256 hash of the file content", - "example": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + "isbns": { + "type": [ + "string", + "null" + ], + "description": "ISBN(s)", + "example": "978-1401207526" }, - "id": { - "type": "string", - "format": "uuid", - "description": "Unique identifier for the duplicate group", - "example": "550e8400-e29b-41d4-a716-446655440000" + "languageIso": { + "type": [ + "string", + "null" + ], + "description": "ISO language code", + "example": "en" }, - "updated_at": { - "type": "string", - "description": "When the group was last updated", - "example": "2024-01-15T10:30:00Z" - } - } - }, - "EntityChangeEvent": { - "allOf": [ - { - "$ref": "#/components/schemas/EntityEvent", - "description": "The specific event that occurred" + "letterer": { + "type": [ + "string", + "null" + ], + "description": "Letterer(s)", + "example": "Todd Klein" }, - { - "type": "object", - "required": [ - "timestamp" + "locks": { + "$ref": "#/components/schemas/BookMetadataLocks", + "description": "Lock states for all metadata fields" + }, + "manga": { + "type": [ + "boolean", + "null" ], - "properties": { - "timestamp": { - "type": "string", - "format": "date-time", - "description": "When the event occurred" - }, - "user_id": { - "type": [ - "string", - "null" - ], - "format": "uuid", - "description": "User who triggered the change (if applicable)" - } - } - } - ], - "description": "Complete entity change event with metadata" - }, - "EntityEvent": { - "oneOf": [ - { - "type": "object", - "description": "A book was created", - "required": [ - "book_id", - "series_id", - "library_id", - "type" + "description": "Whether the book is manga format", + "example": false + }, + "month": { + "type": [ + "integer", + "null" ], - "properties": { - "book_id": { - "type": "string", - "format": "uuid" - }, - "library_id": { - "type": "string", - "format": "uuid" - }, - "series_id": { - "type": "string", - "format": "uuid" - }, - "type": { - "type": "string", - "enum": [ - "book_created" - ] - } - } + "format": "int32", + "description": "Publication month (1-12)", + "example": 2 }, - { - "type": "object", - "description": "A book was updated", - "required": [ - "book_id", - "series_id", - "library_id", - "type" + "number": { + "type": [ + "string", + "null" ], - "properties": { - "book_id": { - "type": "string", - "format": "uuid" - }, - "fields": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "library_id": { - "type": "string", - "format": "uuid" - }, - "series_id": { - "type": "string", - "format": "uuid" - }, - "type": { - "type": "string", - "enum": [ - "book_updated" - ] - } - } + "description": "Chapter/book number", + "example": "1" }, - { - "type": "object", - "description": "A book was deleted", - "required": [ - "book_id", - "series_id", - "library_id", - "type" + "penciller": { + "type": [ + "string", + "null" ], - "properties": { - "book_id": { - "type": "string", - "format": "uuid" - }, - "library_id": { - "type": "string", - "format": "uuid" - }, - "series_id": { - "type": "string", - "format": "uuid" - }, - "type": { - "type": "string", - "enum": [ - "book_deleted" - ] - } - } + "description": "Penciller(s)", + "example": "David Mazzucchelli" }, - { - "type": "object", - "description": "A series was created", - "required": [ - "series_id", - "library_id", - "type" + "publisher": { + "type": [ + "string", + "null" ], - "properties": { - "library_id": { - "type": "string", - "format": "uuid" - }, - "series_id": { - "type": "string", - "format": "uuid" - }, - "type": { - "type": "string", - "enum": [ - "series_created" - ] - } - } + "description": "Publisher name", + "example": "DC Comics" }, - { - "type": "object", - "description": "A series was updated", - "required": [ - "series_id", - "library_id", - "type" + "summary": { + "type": [ + "string", + "null" ], - "properties": { - "fields": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "library_id": { - "type": "string", - "format": "uuid" - }, - "series_id": { - "type": "string", - "format": "uuid" - }, - "type": { - "type": "string", - "enum": [ - "series_updated" - ] - } - } + "description": "Book summary/description", + "example": "Bruce Wayne returns to Gotham City after years abroad." }, - { - "type": "object", - "description": "A series was deleted", - "required": [ - "series_id", - "library_id", - "type" + "title": { + "type": [ + "string", + "null" ], - "properties": { - "library_id": { - "type": "string", - "format": "uuid" - }, - "series_id": { - "type": "string", - "format": "uuid" - }, - "type": { - "type": "string", - "enum": [ - "series_deleted" - ] - } - } + "description": "Book title from metadata", + "example": "Batman: Year One #1" }, - { - "type": "object", - "description": "Deleted books were purged from a series", - "required": [ - "series_id", - "library_id", - "count", - "type" + "titleSort": { + "type": [ + "string", + "null" ], - "properties": { - "count": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "library_id": { - "type": "string", - "format": "uuid" - }, - "series_id": { - "type": "string", - "format": "uuid" - }, - "type": { - "type": "string", - "enum": [ - "series_bulk_purged" - ] - } - } + "description": "Sort title for ordering", + "example": "batman year one 001" }, - { - "type": "object", - "description": "A cover image was updated", - "required": [ - "entity_type", - "entity_id", - "type" + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the metadata was last updated", + "example": "2024-01-15T10:30:00Z" + }, + "volume": { + "type": [ + "integer", + "null" ], - "properties": { - "entity_id": { - "type": "string", - "format": "uuid" - }, - "entity_type": { - "$ref": "#/components/schemas/EntityType" - }, - "library_id": { - "type": [ - "string", - "null" - ], - "format": "uuid" - }, - "type": { - "type": "string", - "enum": [ - "cover_updated" - ] - } - } + "format": "int32", + "description": "Volume number", + "example": 1 }, - { - "type": "object", - "description": "A library was updated", - "required": [ - "library_id", - "type" + "web": { + "type": [ + "string", + "null" ], - "properties": { - "library_id": { - "type": "string", - "format": "uuid" - }, - "type": { - "type": "string", - "enum": [ - "library_updated" - ] - } - } + "description": "Web URL", + "example": "https://dc.com/batman-year-one" }, - { - "type": "object", - "description": "A library was deleted", - "required": [ - "library_id", - "type" + "writer": { + "type": [ + "string", + "null" ], - "properties": { - "library_id": { - "type": "string", - "format": "uuid" - }, - "type": { - "type": "string", - "enum": [ - "library_deleted" - ] - } - } - } - ], - "description": "Specific event types for entity changes" - }, - "EntityType": { - "type": "string", - "description": "Type of entity that was changed", - "enum": [ - "book", - "series", - "library" - ] - }, - "ErrorGroupDto": { - "type": "object", - "description": "Summary of errors grouped by type", - "required": [ - "errorType", - "label", - "count", - "books" - ], - "properties": { - "books": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BookWithErrorsDto" - }, - "description": "Books with this error type (paginated)" - }, - "count": { - "type": "integer", - "format": "int64", - "description": "Number of books with this error type", - "example": 5, - "minimum": 0 - }, - "errorType": { - "$ref": "#/components/schemas/BookErrorTypeDto", - "description": "Error type" + "description": "Writer(s)", + "example": "Frank Miller" }, - "label": { - "type": "string", - "description": "Human-readable label for this error type", - "example": "Parser Error" + "year": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Publication year", + "example": 1987 } } }, - "ErrorResponse": { + "BookListRequest": { "type": "object", - "required": [ - "error", - "message" - ], + "description": "Request body for POST /books/list\n\nPagination parameters (page, pageSize, sort) are passed as query parameters,\nnot in the request body. This enables proper HATEOAS links.", "properties": { - "details": {}, - "error": { - "type": "string" + "condition": { + "type": [ + "object", + "null" + ], + "description": "Filter condition (optional - no condition returns all)" }, - "message": { - "type": "string" + "fullTextSearch": { + "type": [ + "string", + "null" + ], + "description": "Full-text search query (case-insensitive search on book title)" + }, + "includeDeleted": { + "type": "boolean", + "description": "Include soft-deleted books in results (default: false)" } } }, - "ExternalLinkDto": { + "BookMetadataDto": { "type": "object", - "description": "External link data transfer object", + "description": "Book metadata DTO", "required": [ "id", - "seriesId", - "sourceName", - "url", - "createdAt", - "updatedAt" + "bookId", + "writers", + "pencillers", + "inkers", + "colorists", + "letterers", + "coverArtists", + "editors" ], "properties": { - "createdAt": { + "bookId": { "type": "string", - "format": "date-time", - "description": "When the link was created", - "example": "2024-01-01T00:00:00Z" + "format": "uuid", + "description": "Associated book ID", + "example": "550e8400-e29b-41d4-a716-446655440001" }, - "externalId": { + "colorists": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Colorists", + "example": [ + "Richmond Lewis" + ] + }, + "coverArtists": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Cover artists", + "example": [ + "David Mazzucchelli" + ] + }, + "editors": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Editors", + "example": [ + "Dennis O'Neil" + ] + }, + "genre": { "type": [ "string", "null" ], - "description": "ID on the external source (if available)", - "example": "12345" + "description": "Genre", + "example": "Superhero" }, "id": { "type": "string", "format": "uuid", - "description": "External link ID", - "example": "550e8400-e29b-41d4-a716-446655440060" - }, - "seriesId": { - "type": "string", - "format": "uuid", - "description": "Series ID", - "example": "550e8400-e29b-41d4-a716-446655440002" - }, - "sourceName": { - "type": "string", - "description": "Source name (e.g., \"myanimelist\", \"anilist\", \"mangadex\")", - "example": "myanimelist" + "description": "Metadata record ID", + "example": "550e8400-e29b-41d4-a716-446655440003" }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the link was last updated", - "example": "2024-01-15T10:30:00Z" + "imprint": { + "type": [ + "string", + "null" + ], + "description": "Imprint name", + "example": "DC Black Label" }, - "url": { - "type": "string", - "description": "URL to the external source", - "example": "https://myanimelist.net/manga/12345" - } - } - }, - "ExternalLinkListResponse": { - "type": "object", - "description": "Response containing a list of external links", - "required": [ - "links" - ], - "properties": { - "links": { + "inkers": { "type": "array", "items": { - "$ref": "#/components/schemas/ExternalLinkDto" + "type": "string" }, - "description": "List of external links" - } - } - }, - "ExternalRatingDto": { - "type": "object", - "description": "External rating data transfer object", - "required": [ - "id", - "seriesId", - "sourceName", - "rating", - "fetchedAt", - "createdAt", - "updatedAt" - ], - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the rating record was created", - "example": "2024-01-01T00:00:00Z" - }, - "fetchedAt": { - "type": "string", - "format": "date-time", - "description": "When the rating was last fetched from the source", - "example": "2024-01-15T10:30:00Z" - }, - "id": { - "type": "string", - "format": "uuid", - "description": "External rating ID", - "example": "550e8400-e29b-41d4-a716-446655440050" - }, - "rating": { - "type": "number", - "format": "double", - "description": "Rating value (0-100)", - "example": 85.5 + "description": "Inkers", + "example": [ + "David Mazzucchelli" + ] }, - "seriesId": { - "type": "string", - "format": "uuid", - "description": "Series ID", - "example": "550e8400-e29b-41d4-a716-446655440002" + "languageIso": { + "type": [ + "string", + "null" + ], + "description": "ISO language code", + "example": "en" }, - "sourceName": { - "type": "string", - "description": "Source name (e.g., \"myanimelist\", \"anilist\", \"mangabaka\")", - "example": "myanimelist" + "letterers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Letterers", + "example": [ + "Todd Klein" + ] }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the rating record was last updated", - "example": "2024-01-15T10:30:00Z" + "number": { + "type": [ + "string", + "null" + ], + "description": "Issue/chapter number from metadata", + "example": "1" }, - "voteCount": { + "pageCount": { "type": [ "integer", "null" ], "format": "int32", - "description": "Number of votes (if available)", - "example": 12500 - } - } - }, - "ExternalRatingListResponse": { - "type": "object", - "description": "Response containing a list of external ratings", - "required": [ - "ratings" - ], - "properties": { - "ratings": { + "description": "Page count from metadata", + "example": 32 + }, + "pencillers": { "type": "array", "items": { - "$ref": "#/components/schemas/ExternalRatingDto" + "type": "string" }, - "description": "List of external ratings" - } - } - }, - "FeedMetadata": { - "type": "object", - "description": "OPDS 2.0 Feed Metadata\n\nMetadata for navigation and publication feeds.", - "required": [ - "title" - ], - "properties": { - "currentPage": { + "description": "Pencillers (line artists)", + "example": [ + "David Mazzucchelli" + ] + }, + "publisher": { "type": [ - "integer", + "string", "null" ], - "format": "int32", - "description": "Current page number (for pagination)" + "description": "Publisher name", + "example": "DC Comics" }, - "itemsPerPage": { + "releaseDate": { "type": [ - "integer", + "string", "null" ], - "format": "int32", - "description": "Items per page (for pagination)" + "format": "date-time", + "description": "Release/publication date", + "example": "1987-02-01T00:00:00Z" }, - "modified": { + "series": { "type": [ "string", "null" ], - "format": "date-time", - "description": "Last modification date" + "description": "Series name from metadata", + "example": "Batman: Year One" }, - "numberOfItems": { + "summary": { "type": [ - "integer", + "string", "null" ], - "format": "int64", - "description": "Total number of items in the collection (for pagination)" + "description": "Book summary/description", + "example": "Bruce Wayne returns to Gotham City after years abroad to begin his war on crime." }, - "subtitle": { + "title": { "type": [ "string", "null" ], - "description": "Optional subtitle" + "description": "Book title from metadata", + "example": "Batman: Year One #1" }, - "title": { - "type": "string", - "description": "Title of the feed" + "writers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Writers/authors", + "example": [ + "Frank Miller" + ] } } }, - "FieldOperator": { - "oneOf": [ - { - "type": "object", - "description": "Exact match", - "required": [ - "value", - "operator" - ], - "properties": { - "operator": { - "type": "string", - "enum": [ - "is" - ] - }, - "value": { - "type": "string" - } - } + "BookMetadataLocks": { + "type": "object", + "description": "Book metadata lock states\n\nIndicates which metadata fields are locked (protected from automatic updates).\nWhen a field is locked, the scanner will not overwrite user-edited values.", + "required": [ + "summaryLock", + "writerLock", + "pencillerLock", + "inkerLock", + "coloristLock", + "lettererLock", + "coverArtistLock", + "editorLock", + "publisherLock", + "imprintLock", + "genreLock", + "webLock", + "languageIsoLock", + "formatDetailLock", + "blackAndWhiteLock", + "mangaLock", + "yearLock", + "monthLock", + "dayLock", + "volumeLock", + "countLock", + "isbnsLock" + ], + "properties": { + "blackAndWhiteLock": { + "type": "boolean", + "description": "Whether black_and_white is locked", + "example": false }, - { - "type": "object", - "description": "Not equal", - "required": [ - "value", - "operator" - ], - "properties": { - "operator": { - "type": "string", - "enum": [ - "isNot" - ] - }, - "value": { - "type": "string" - } - } + "coloristLock": { + "type": "boolean", + "description": "Whether colorist is locked", + "example": false }, - { - "type": "object", - "description": "Field is null/empty", - "required": [ - "operator" - ], - "properties": { - "operator": { - "type": "string", - "enum": [ - "isNull" - ] - } - } + "countLock": { + "type": "boolean", + "description": "Whether count is locked", + "example": false }, - { - "type": "object", - "description": "Field is not null/empty", - "required": [ - "operator" - ], - "properties": { - "operator": { - "type": "string", - "enum": [ - "isNotNull" - ] - } - } + "coverArtistLock": { + "type": "boolean", + "description": "Whether cover artist is locked", + "example": false }, - { - "type": "object", - "description": "String contains (case-insensitive)", - "required": [ - "value", - "operator" - ], - "properties": { - "operator": { - "type": "string", - "enum": [ - "contains" - ] - }, - "value": { - "type": "string" - } - } + "dayLock": { + "type": "boolean", + "description": "Whether day is locked", + "example": false }, - { - "type": "object", - "description": "String does not contain (case-insensitive)", - "required": [ - "value", - "operator" - ], - "properties": { - "operator": { - "type": "string", - "enum": [ - "doesNotContain" - ] - }, - "value": { - "type": "string" - } - } + "editorLock": { + "type": "boolean", + "description": "Whether editor is locked", + "example": false }, - { - "type": "object", - "description": "String starts with (case-insensitive)", - "required": [ - "value", - "operator" - ], - "properties": { - "operator": { - "type": "string", - "enum": [ - "beginsWith" - ] - }, - "value": { - "type": "string" - } - } + "formatDetailLock": { + "type": "boolean", + "description": "Whether format_detail is locked", + "example": false }, - { - "type": "object", - "description": "String ends with (case-insensitive)", - "required": [ - "value", - "operator" - ], - "properties": { - "operator": { - "type": "string", - "enum": [ - "endsWith" - ] - }, - "value": { - "type": "string" - } - } - } - ], - "description": "Operators for string and equality comparisons" - }, - "FileSystemEntry": { - "type": "object", - "required": [ - "name", - "path", - "is_directory", - "is_readable" - ], - "properties": { - "is_directory": { + "genreLock": { "type": "boolean", - "description": "Whether this is a directory" + "description": "Whether genre is locked", + "example": false }, - "is_readable": { + "imprintLock": { "type": "boolean", - "description": "Whether the entry is readable" + "description": "Whether imprint is locked", + "example": false }, - "name": { - "type": "string", - "description": "Name of the file or directory" + "inkerLock": { + "type": "boolean", + "description": "Whether inker is locked", + "example": false }, - "path": { - "type": "string", - "description": "Full path to the entry" - } - }, - "example": { - "is_directory": true, - "is_readable": true, - "name": "Documents", - "path": "/home/user/Documents" - } - }, - "FlatStrategyConfig": { - "type": "object", - "description": "Configuration for flat scanning strategy", - "properties": { - "filenamePatterns": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Regex patterns for extracting series name from filename\nPatterns are tried in order, first match wins" + "isbnsLock": { + "type": "boolean", + "description": "Whether isbns is locked", + "example": false }, - "requireMetadata": { + "languageIsoLock": { "type": "boolean", - "description": "If true, require metadata for series detection (no filename fallback)" - } - } - }, - "ForceRequest": { - "type": "object", - "properties": { - "force": { + "description": "Whether language_iso is locked", + "example": false + }, + "lettererLock": { "type": "boolean", - "description": "If true, regenerate thumbnails even if they exist. If false (default), only generate missing thumbnails.", + "description": "Whether letterer is locked", + "example": false + }, + "mangaLock": { + "type": "boolean", + "description": "Whether manga is locked", + "example": false + }, + "monthLock": { + "type": "boolean", + "description": "Whether month is locked", + "example": false + }, + "pencillerLock": { + "type": "boolean", + "description": "Whether penciller is locked", + "example": false + }, + "publisherLock": { + "type": "boolean", + "description": "Whether publisher is locked", + "example": true + }, + "summaryLock": { + "type": "boolean", + "description": "Whether summary is locked", + "example": false + }, + "volumeLock": { + "type": "boolean", + "description": "Whether volume is locked", + "example": false + }, + "webLock": { + "type": "boolean", + "description": "Whether web URL is locked", + "example": false + }, + "writerLock": { + "type": "boolean", + "description": "Whether writer is locked", "example": false + }, + "yearLock": { + "type": "boolean", + "description": "Whether year is locked", + "example": true } } }, - "FullBookResponse": { + "BookMetadataResponse": { "type": "object", - "description": "Full book response including book data and complete metadata with locks", + "description": "Response containing book metadata", "required": [ - "id", - "libraryId", - "libraryName", - "seriesId", - "seriesName", - "title", - "filePath", - "fileFormat", - "fileSize", - "fileHash", - "pageCount", - "deleted", - "metadata", - "createdAt", + "bookId", "updatedAt" ], "properties": { - "analysisError": { + "blackAndWhite": { "type": [ - "string", + "boolean", "null" ], - "description": "Error message if book analysis failed", - "example": "Failed to parse CBZ: invalid archive" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the book was added to the library", - "example": "2024-01-01T00:00:00Z" - }, - "deleted": { - "type": "boolean", - "description": "Whether the book has been soft-deleted", + "description": "Whether the book is black and white", "example": false }, - "fileFormat": { + "bookId": { "type": "string", - "description": "File format (cbz, cbr, epub, pdf)", - "example": "cbz" + "format": "uuid", + "description": "Book ID", + "example": "550e8400-e29b-41d4-a716-446655440001" }, - "fileHash": { - "type": "string", - "description": "File hash for deduplication", - "example": "a1b2c3d4e5f6g7h8i9j0" - }, - "filePath": { - "type": "string", - "description": "Filesystem path to the book file", - "example": "/media/comics/Batman/Batman - Year One 001.cbz" - }, - "fileSize": { - "type": "integer", - "format": "int64", - "description": "File size in bytes", - "example": 52428800 - }, - "id": { - "type": "string", - "format": "uuid", - "description": "Book unique identifier", - "example": "550e8400-e29b-41d4-a716-446655440001" - }, - "libraryId": { - "type": "string", - "format": "uuid", - "description": "Library this book belongs to", - "example": "550e8400-e29b-41d4-a716-446655440000" - }, - "libraryName": { - "type": "string", - "description": "Name of the library", - "example": "Comics" - }, - "metadata": { - "$ref": "#/components/schemas/BookFullMetadata", - "description": "Complete book metadata with lock states" - }, - "number": { + "colorist": { "type": [ - "integer", + "string", "null" ], - "format": "int32", - "description": "Book number within the series", - "example": 1 - }, - "pageCount": { - "type": "integer", - "format": "int32", - "description": "Number of pages in the book", - "example": 32 - }, - "readProgress": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ReadProgressResponse", - "description": "User's read progress for this book" - } - ] + "description": "Colorist(s)", + "example": "Richmond Lewis" }, - "readingDirection": { + "count": { "type": [ - "string", + "integer", "null" ], - "description": "Effective reading direction (from series metadata, or library default)", - "example": "ltr" - }, - "seriesId": { - "type": "string", - "format": "uuid", - "description": "Series this book belongs to", - "example": "550e8400-e29b-41d4-a716-446655440002" - }, - "seriesName": { - "type": "string", - "description": "Name of the series", - "example": "Batman: Year One" - }, - "title": { - "type": "string", - "description": "Book title (display name)", - "example": "Batman: Year One #1" + "format": "int32", + "description": "Total count in series", + "example": 4 }, - "titleSort": { + "coverArtist": { "type": [ "string", "null" ], - "description": "Title used for sorting", - "example": "batman year one 001" + "description": "Cover artist(s)", + "example": "David Mazzucchelli" }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the book was last updated", - "example": "2024-01-15T10:30:00Z" - } - } - }, - "FullSeriesMetadataResponse": { - "type": "object", - "description": "Full series metadata response including all related data", - "required": [ - "seriesId", - "title", - "locks", - "genres", - "tags", - "alternateTitles", - "externalRatings", - "externalLinks", - "createdAt", - "updatedAt" - ], - "properties": { - "ageRating": { + "day": { "type": [ "integer", "null" ], "format": "int32", - "description": "Age rating (e.g., 13, 16, 18)", - "example": 16 - }, - "alternateTitles": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AlternateTitleDto" - }, - "description": "Alternate titles for this series" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "Timestamps", - "example": "2024-01-01T00:00:00Z" + "description": "Publication day (1-31)", + "example": 1 }, - "customMetadata": { + "editor": { "type": [ - "object", + "string", "null" ], - "description": "Custom JSON metadata" - }, - "externalLinks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ExternalLinkDto" - }, - "description": "External links to other sites" - }, - "externalRatings": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ExternalRatingDto" - }, - "description": "External ratings from various sources" - }, - "genres": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GenreDto" - }, - "description": "Genres assigned to this series" + "description": "Editor(s)", + "example": "Dennis O'Neil" }, - "imprint": { + "formatDetail": { "type": [ "string", "null" ], - "description": "Imprint (sub-publisher)", - "example": "Vertigo" + "description": "Format details", + "example": "Trade Paperback" }, - "language": { + "genre": { "type": [ "string", "null" ], - "description": "Language (BCP47 format: \"en\", \"ja\", \"ko\")", - "example": "en" - }, - "locks": { - "$ref": "#/components/schemas/MetadataLocks", - "description": "Lock states for all metadata fields" + "description": "Genre", + "example": "Superhero" }, - "publisher": { + "imprint": { "type": [ "string", "null" ], - "description": "Publisher name", - "example": "DC Comics" + "description": "Imprint name", + "example": "DC Black Label" }, - "readingDirection": { + "inker": { "type": [ "string", "null" ], - "description": "Reading direction (ltr, rtl, ttb or webtoon)", - "example": "ltr" - }, - "seriesId": { - "type": "string", - "format": "uuid", - "description": "Series ID", - "example": "550e8400-e29b-41d4-a716-446655440002" + "description": "Inker(s)", + "example": "David Mazzucchelli" }, - "status": { + "isbns": { "type": [ "string", "null" ], - "description": "Series status (ongoing, ended, hiatus, abandoned, unknown)", - "example": "ended" + "description": "ISBN(s)", + "example": "978-1401207526" }, - "summary": { + "languageIso": { "type": [ "string", "null" ], - "description": "Series description/summary", - "example": "The definitive origin story of Batman." - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TagDto" - }, - "description": "Tags assigned to this series" - }, - "title": { - "type": "string", - "description": "Series title (usually same as series name)", - "example": "Batman: Year One" + "description": "ISO language code", + "example": "en" }, - "titleSort": { + "letterer": { "type": [ "string", "null" ], - "description": "Custom sort name for ordering", - "example": "Batman Year One" + "description": "Letterer(s)", + "example": "Todd Klein" }, - "totalBookCount": { + "manga": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the book is manga format", + "example": false + }, + "month": { "type": [ "integer", "null" ], "format": "int32", - "description": "Expected total book count (for ongoing series)", - "example": 4 + "description": "Publication month (1-12)", + "example": 2 }, - "updatedAt": { - "type": "string", - "format": "date-time", - "example": "2024-01-15T10:30:00Z" + "penciller": { + "type": [ + "string", + "null" + ], + "description": "Penciller(s)", + "example": "David Mazzucchelli" }, - "year": { + "publisher": { "type": [ - "integer", + "string", "null" ], - "format": "int32", - "description": "Release year", - "example": 1987 - } - } - }, - "FullSeriesResponse": { - "type": "object", - "description": "Full series response including series data and complete metadata", - "required": [ - "id", - "libraryId", - "libraryName", - "bookCount", - "metadata", - "genres", - "tags", - "alternateTitles", - "externalRatings", - "externalLinks", - "createdAt", - "updatedAt" - ], - "properties": { - "alternateTitles": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AlternateTitleDto" - }, - "description": "Alternate titles for this series" - }, - "bookCount": { - "type": "integer", - "format": "int64", - "description": "Total number of books in this series", - "example": 4 - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the series was created", - "example": "2024-01-01T00:00:00Z" - }, - "externalLinks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ExternalLinkDto" - }, - "description": "External links to other sites" - }, - "externalRatings": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ExternalRatingDto" - }, - "description": "External ratings from various sources" - }, - "genres": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GenreDto" - }, - "description": "Genres assigned to this series" + "description": "Publisher name", + "example": "DC Comics" }, - "hasCustomCover": { + "summary": { "type": [ - "boolean", + "string", "null" ], - "description": "Whether the series has a custom cover uploaded", - "example": false - }, - "id": { - "type": "string", - "format": "uuid", - "description": "Series unique identifier", - "example": "550e8400-e29b-41d4-a716-446655440002" - }, - "libraryId": { - "type": "string", - "format": "uuid", - "description": "Library unique identifier", - "example": "550e8400-e29b-41d4-a716-446655440000" + "description": "Book summary/description", + "example": "Bruce Wayne returns to Gotham City." }, - "libraryName": { + "updatedAt": { "type": "string", - "description": "Name of the library this series belongs to", - "example": "Comics" - }, - "metadata": { - "$ref": "#/components/schemas/SeriesFullMetadata", - "description": "Complete series metadata" + "format": "date-time", + "description": "Last update timestamp", + "example": "2024-01-15T10:30:00Z" }, - "path": { + "volume": { "type": [ - "string", + "integer", "null" ], - "description": "Filesystem path to the series directory", - "example": "/media/comics/Batman - Year One" + "format": "int32", + "description": "Volume number", + "example": 1 }, - "selectedCoverSource": { + "web": { "type": [ "string", "null" ], - "description": "Selected cover source (e.g., \"first_book\", \"custom\")", - "example": "first_book" - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TagDto" - }, - "description": "Tags assigned to this series" - }, - "unreadCount": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "description": "Number of unread books in this series (user-specific)", - "example": 2 - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the series was last updated", - "example": "2024-01-15T10:30:00Z" - } - } - }, - "GenerateThumbnailsRequest": { - "type": "object", - "properties": { - "force": { - "type": "boolean", - "description": "If true, regenerate all thumbnails even if they exist. If false (default), only generate missing thumbnails.", - "example": false + "description": "Web URL", + "example": "https://dc.com/batman-year-one" }, - "library_id": { + "writer": { "type": [ "string", "null" ], - "format": "uuid", - "description": "Library ID to generate thumbnails for (optional)", - "example": "550e8400-e29b-41d4-a716-446655440000" + "description": "Writer(s)", + "example": "Frank Miller" }, - "series_id": { + "year": { "type": [ - "string", + "integer", "null" ], - "format": "uuid", - "description": "Series ID to generate thumbnails for (optional, takes precedence over library_id)", - "example": "550e8400-e29b-41d4-a716-446655440001" + "format": "int32", + "description": "Publication year", + "example": 1987 } } }, - "GenreDto": { + "BookStrategy": { + "type": "string", + "description": "Book naming strategy type for determining book titles\n\nDetermines how individual book titles and numbers are resolved.", + "enum": [ + "filename", + "metadata_first", + "smart", + "series_name", + "custom" + ] + }, + "BookUpdateResponse": { "type": "object", - "description": "Genre data transfer object", + "description": "Response for book update", "required": [ "id", - "name", - "createdAt" + "updatedAt" ], "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the genre was created", - "example": "2024-01-01T00:00:00Z" - }, "id": { "type": "string", "format": "uuid", - "description": "Genre ID", - "example": "550e8400-e29b-41d4-a716-446655440010" + "description": "Book ID", + "example": "550e8400-e29b-41d4-a716-446655440001" }, - "name": { - "type": "string", - "description": "Genre name", - "example": "Action" + "number": { + "type": [ + "number", + "null" + ], + "format": "double", + "description": "Updated number", + "example": 1.5 }, - "seriesCount": { + "title": { "type": [ - "integer", + "string", "null" ], - "format": "int64", - "description": "Number of series with this genre", - "example": 42, - "minimum": 0 + "description": "Updated title", + "example": "Chapter 1: The Beginning" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Last update timestamp", + "example": "2024-01-15T10:30:00Z" } } }, - "GenreListResponse": { + "BookWithErrorsDto": { "type": "object", - "description": "Response containing a list of genres", + "description": "A book with its associated errors", "required": [ - "genres" + "book", + "errors" ], "properties": { - "genres": { + "book": { + "$ref": "#/components/schemas/BookDto", + "description": "The book data" + }, + "errors": { "type": "array", "items": { - "$ref": "#/components/schemas/GenreDto" + "$ref": "#/components/schemas/BookErrorDto" }, - "description": "List of genres" + "description": "All errors for this book" } } }, - "Group": { + "BooksPaginationQuery": { "type": "object", - "description": "A group containing navigation or publications\n\nGroups allow organizing multiple collections within a single feed.", - "required": [ - "metadata" - ], + "description": "Query parameters for paginated book endpoints", "properties": { - "metadata": { - "$ref": "#/components/schemas/FeedMetadata", - "description": "Group metadata (title required)" + "page": { + "type": "integer", + "format": "int32", + "description": "Page number (0-indexed, Komga-style)" }, - "navigation": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/components/schemas/Opds2Link" - }, - "description": "Navigation links within this group" + "size": { + "type": "integer", + "format": "int32", + "description": "Page size (default: 20)" }, - "publications": { + "sort": { "type": [ - "array", + "string", "null" ], - "items": { - "$ref": "#/components/schemas/Publication" - }, - "description": "Publications within this group" + "description": "Sort parameter (e.g., \"createdDate,desc\", \"metadata.numberSort,asc\")" } } }, - "ImageLink": { + "BooksWithErrorsResponse": { "type": "object", - "description": "Image link with optional dimensions\n\nUsed for cover images and thumbnails in publications.", + "description": "Response for listing books with errors", "required": [ - "href", - "type" + "totalBooksWithErrors", + "errorCounts", + "groups", + "page", + "pageSize", + "totalPages" ], "properties": { - "height": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Height in pixels" + "errorCounts": { + "type": "object", + "description": "Count of books by error type", + "additionalProperties": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "propertyNames": { + "type": "string" + }, + "example": { + "parser": 5, + "thumbnail": 10 + } }, - "href": { - "type": "string", - "description": "URL to the image" + "groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ErrorGroupDto" + }, + "description": "Error groups with books" }, - "type": { - "type": "string", - "description": "Media type of the image" + "page": { + "type": "integer", + "format": "int64", + "description": "Current page (0-indexed)", + "example": 0, + "minimum": 0 }, - "width": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Width in pixels" + "pageSize": { + "type": "integer", + "format": "int64", + "description": "Page size", + "example": 20, + "minimum": 0 + }, + "totalBooksWithErrors": { + "type": "integer", + "format": "int64", + "description": "Total number of books with errors", + "example": 15, + "minimum": 0 + }, + "totalPages": { + "type": "integer", + "format": "int64", + "description": "Total number of pages", + "example": 1, + "minimum": 0 } } }, - "InitializeSetupRequest": { + "BoolOperator": { + "oneOf": [ + { + "type": "object", + "required": [ + "operator" + ], + "properties": { + "operator": { + "type": "string", + "enum": [ + "isTrue" + ] + } + } + }, + { + "type": "object", + "required": [ + "operator" + ], + "properties": { + "operator": { + "type": "string", + "enum": [ + "isFalse" + ] + } + } + } + ], + "description": "Operators for boolean comparisons" + }, + "BrandingSettingsDto": { "type": "object", - "description": "Initialize setup request - creates first admin user", + "description": "Branding settings DTO (unauthenticated access)\n\nContains branding-related settings that can be accessed without authentication.\nUsed on the login page and other unauthenticated UI surfaces.", "required": [ - "username", - "email", - "password" + "application_name" ], "properties": { - "email": { - "type": "string", - "description": "Email address for the first admin user" - }, - "password": { - "type": "string", - "description": "Password for the first admin user" - }, - "username": { + "application_name": { "type": "string", - "description": "Username for the first admin user" + "description": "The application name to display", + "example": "Codex" } } }, - "InitializeSetupResponse": { + "BrowseResponse": { "type": "object", - "description": "Initialize setup response - returns user and JWT token", "required": [ - "user", - "accessToken", - "tokenType", - "expiresIn", - "message" + "current_path", + "entries" ], "properties": { - "accessToken": { - "type": "string", - "description": "JWT access token" - }, - "expiresIn": { - "type": "integer", - "format": "int64", - "description": "Token expiry in seconds", - "minimum": 0 - }, - "message": { + "current_path": { "type": "string", - "description": "Success message" + "description": "Current directory path" }, - "tokenType": { - "type": "string", - "description": "Token type (always \"Bearer\")" + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileSystemEntry" + }, + "description": "List of entries in the current directory" }, - "user": { - "$ref": "#/components/schemas/UserInfo", - "description": "Created user information" + "parent_path": { + "type": [ + "string", + "null" + ], + "description": "Parent directory path (None if at root)" } + }, + "example": { + "current_path": "/home/user/Documents", + "entries": [ + { + "is_directory": true, + "is_readable": true, + "name": "Comics", + "path": "/home/user/Documents/Comics" + }, + { + "is_directory": true, + "is_readable": true, + "name": "Manga", + "path": "/home/user/Documents/Manga" + } + ], + "parent_path": "/home/user" } }, - "KomgaAgeRestrictionDto": { + "BulkAnalyzeBooksRequest": { "type": "object", - "description": "Komga age restriction DTO", + "description": "Request to perform bulk analyze operations on multiple books", "required": [ - "age", - "restriction" + "bookIds" ], "properties": { - "age": { - "type": "integer", - "format": "int32", - "description": "Age limit" + "bookIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "List of book IDs to analyze", + "example": [ + "550e8400-e29b-41d4-a716-446655440001", + "550e8400-e29b-41d4-a716-446655440002" + ] }, - "restriction": { - "type": "string", - "description": "Restriction type (ALLOW_ONLY, EXCLUDE)" + "force": { + "type": "boolean", + "description": "Whether to force re-analysis of already analyzed books", + "example": false } } }, - "KomgaAlternateTitleDto": { + "BulkAnalyzeResponse": { "type": "object", - "description": "Komga alternate title DTO", + "description": "Response for bulk analyze operations", "required": [ - "label", - "title" + "tasksEnqueued", + "message" ], "properties": { - "label": { + "message": { "type": "string", - "description": "Title label (e.g., \"Japanese\", \"Romaji\")" + "description": "Message describing the operation", + "example": "Enqueued 5 analysis tasks" }, - "title": { - "type": "string", - "description": "The alternate title text" + "tasksEnqueued": { + "type": "integer", + "description": "Number of analysis tasks enqueued", + "example": 5, + "minimum": 0 } } }, - "KomgaAuthorDto": { + "BulkAnalyzeSeriesRequest": { "type": "object", - "description": "Komga author DTO", + "description": "Request to perform bulk analyze operations on multiple series", "required": [ - "name", - "role" + "seriesIds" ], "properties": { - "name": { - "type": "string", - "description": "Author name" + "force": { + "type": "boolean", + "description": "Whether to force re-analysis of already analyzed books", + "example": false }, - "role": { - "type": "string", - "description": "Author role (WRITER, PENCILLER, INKER, COLORIST, LETTERER, COVER, EDITOR)" + "seriesIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "List of series IDs to analyze", + "example": [ + "550e8400-e29b-41d4-a716-446655440001", + "550e8400-e29b-41d4-a716-446655440002" + ] } } }, - "KomgaBookDto": { + "BulkBooksRequest": { "type": "object", - "description": "Komga book DTO\n\nBased on actual Komic traffic analysis. This is the main book representation.", + "description": "Request to perform bulk operations on multiple books", "required": [ - "id", - "seriesId", - "seriesTitle", - "libraryId", - "name", - "url", - "number", - "created", - "lastModified", - "fileLastModified", - "sizeBytes", - "size", - "media", - "metadata" + "bookIds" ], "properties": { - "created": { - "type": "string", - "description": "Created timestamp (ISO 8601)" - }, - "deleted": { - "type": "boolean", - "description": "Whether book is deleted (soft delete)" - }, - "fileHash": { - "type": "string", - "description": "File hash" - }, - "fileLastModified": { - "type": "string", - "description": "File last modified timestamp (ISO 8601)" - }, - "id": { - "type": "string", - "description": "Book unique identifier (UUID as string)" - }, - "lastModified": { - "type": "string", - "description": "Last modified timestamp (ISO 8601)" - }, - "libraryId": { - "type": "string", - "description": "Library ID" - }, - "media": { - "$ref": "#/components/schemas/KomgaMediaDto", - "description": "Media information" - }, - "metadata": { - "$ref": "#/components/schemas/KomgaBookMetadataDto", - "description": "Book metadata" - }, - "name": { - "type": "string", - "description": "Book filename/name" - }, - "number": { - "type": "integer", - "format": "int32", - "description": "Book number in series" - }, - "oneshot": { - "type": "boolean", - "description": "Whether this is a oneshot" - }, - "readProgress": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/KomgaReadProgressDto", - "description": "User's read progress (null if not started)" - } + "bookIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "List of book IDs to operate on", + "example": [ + "550e8400-e29b-41d4-a716-446655440001", + "550e8400-e29b-41d4-a716-446655440002" ] - }, - "seriesId": { - "type": "string", - "description": "Series ID" - }, - "seriesTitle": { - "type": "string", - "description": "Series title (required by Komic for display)" - }, - "size": { - "type": "string", - "description": "Human-readable file size (e.g., \"869.9 MiB\")" - }, - "sizeBytes": { - "type": "integer", - "format": "int64", - "description": "File size in bytes" - }, - "url": { - "type": "string", - "description": "File URL/path" } } }, - "KomgaBookLinkDto": { + "BulkSeriesRequest": { "type": "object", - "description": "Komga book link DTO", + "description": "Request to perform bulk operations on multiple series", "required": [ - "label", - "url" + "seriesIds" ], "properties": { - "label": { + "seriesIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "List of series IDs to operate on", + "example": [ + "550e8400-e29b-41d4-a716-446655440001", + "550e8400-e29b-41d4-a716-446655440002" + ] + } + } + }, + "BulkSetPreferencesRequest": { + "type": "object", + "description": "Request to set multiple preferences at once", + "required": [ + "preferences" + ], + "properties": { + "preferences": { + "type": "object", + "description": "Map of preference keys to values", + "additionalProperties": {}, + "propertyNames": { + "type": "string" + }, + "example": { + "reader.zoom": 150, + "ui.theme": "dark" + } + } + } + }, + "BulkSettingUpdate": { + "type": "object", + "description": "Single setting update in a bulk operation", + "required": [ + "key", + "value" + ], + "properties": { + "key": { "type": "string", - "description": "Link label" + "description": "Setting key to update", + "example": "scan.concurrent_jobs" }, - "url": { + "value": { "type": "string", - "description": "Link URL" + "description": "New value for the setting", + "example": "4" } } }, - "KomgaBookMetadataDto": { + "BulkUpdateSettingsRequest": { "type": "object", - "description": "Komga book metadata DTO", + "description": "Bulk update settings request", "required": [ - "title", - "number", - "numberSort", - "created", - "lastModified" + "updates" ], "properties": { - "authors": { + "change_reason": { + "type": [ + "string", + "null" + ], + "description": "Optional reason for the changes (for audit log)", + "example": "Batch configuration update for production" + }, + "updates": { "type": "array", "items": { - "$ref": "#/components/schemas/KomgaAuthorDto" + "$ref": "#/components/schemas/BulkSettingUpdate" }, - "description": "Authors list" - }, - "authorsLock": { + "description": "List of settings to update" + } + } + }, + "CalibreSeriesMode": { + "type": "string", + "description": "How Calibre strategy groups books into series", + "enum": [ + "standalone", + "by_author", + "from_metadata" + ] + }, + "CalibreStrategyConfig": { + "type": "object", + "description": "Configuration for Calibre strategy", + "properties": { + "authorFromFolder": { "type": "boolean", - "description": "Whether authors are locked" + "description": "Use author folder name as author metadata" }, - "created": { - "type": "string", - "description": "Created timestamp (ISO 8601)" + "readOpfMetadata": { + "type": "boolean", + "description": "Read metadata.opf files for rich metadata" }, - "isbn": { - "type": "string", - "description": "ISBN" + "seriesMode": { + "$ref": "#/components/schemas/CalibreSeriesMode", + "description": "How to group books into series" }, - "isbnLock": { + "stripIdSuffix": { "type": "boolean", - "description": "Whether ISBN is locked" + "description": "Strip Calibre ID suffix from folder names (e.g., \" (123)\")" + } + } + }, + "CleanupResultDto": { + "type": "object", + "description": "Result of a cleanup operation", + "required": [ + "thumbnails_deleted", + "covers_deleted", + "bytes_freed", + "failures" + ], + "properties": { + "bytes_freed": { + "type": "integer", + "format": "int64", + "description": "Total bytes freed by deletion", + "example": 1073741824, + "minimum": 0 }, - "lastModified": { - "type": "string", - "description": "Last modified timestamp (ISO 8601)" + "covers_deleted": { + "type": "integer", + "format": "int32", + "description": "Number of cover files deleted", + "example": 5, + "minimum": 0 }, - "links": { + "errors": { "type": "array", "items": { - "$ref": "#/components/schemas/KomgaBookLinkDto" + "type": "string" }, - "description": "Links" - }, - "linksLock": { - "type": "boolean", - "description": "Whether links are locked" + "description": "Error messages for any failed deletions" }, - "number": { - "type": "string", - "description": "Book number (display string)" - }, - "numberLock": { - "type": "boolean", - "description": "Whether number is locked" - }, - "numberSort": { - "type": "number", - "format": "double", - "description": "Number for sorting (float for chapter ordering)" - }, - "numberSortLock": { - "type": "boolean", - "description": "Whether number_sort is locked" - }, - "releaseDate": { - "type": [ - "string", - "null" - ], - "description": "Release date (YYYY-MM-DD or full ISO 8601)" - }, - "releaseDateLock": { - "type": "boolean", - "description": "Whether release_date is locked" - }, - "summary": { - "type": "string", - "description": "Book summary" - }, - "summaryLock": { - "type": "boolean", - "description": "Whether summary is locked" + "failures": { + "type": "integer", + "format": "int32", + "description": "Number of files that failed to delete", + "example": 0, + "minimum": 0 }, - "tags": { - "type": "array", - "items": { + "thumbnails_deleted": { + "type": "integer", + "format": "int32", + "description": "Number of thumbnail files deleted", + "example": 42, + "minimum": 0 + } + } + }, + "ConfigureSettingsRequest": { + "type": "object", + "description": "Configure initial settings request", + "required": [ + "settings", + "skipConfiguration" + ], + "properties": { + "settings": { + "type": "object", + "description": "Settings to configure (key-value pairs)", + "additionalProperties": { "type": "string" }, - "description": "Tags list" - }, - "tagsLock": { - "type": "boolean", - "description": "Whether tags are locked" - }, - "title": { - "type": "string", - "description": "Book title" + "propertyNames": { + "type": "string" + } }, - "titleLock": { + "skipConfiguration": { "type": "boolean", - "description": "Whether title is locked" + "description": "Whether to skip settings configuration" } } }, - "KomgaBooksMetadataAggregationDto": { + "ConfigureSettingsResponse": { "type": "object", - "description": "Komga books metadata aggregation DTO\n\nAggregated metadata from all books in the series.", + "description": "Configure settings response", "required": [ - "created", - "lastModified" + "message", + "settingsConfigured" ], "properties": { - "authors": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KomgaAuthorDto" - }, - "description": "Authors from all books" - }, - "created": { + "message": { "type": "string", - "description": "Created timestamp (ISO 8601)" + "description": "Success message" }, - "lastModified": { + "settingsConfigured": { + "type": "integer", + "description": "Number of settings configured", + "minimum": 0 + } + } + }, + "Contributor": { + "type": "object", + "description": "Contributor information (author, artist, etc.)", + "required": [ + "name" + ], + "properties": { + "name": { "type": "string", - "description": "Last modified timestamp (ISO 8601)" + "description": "Name of the contributor" }, - "releaseDate": { + "sortAs": { "type": [ "string", "null" ], - "description": "Release date range (earliest)" - }, - "summary": { + "description": "Sort-friendly version of the name" + } + } + }, + "CreateAlternateTitleRequest": { + "type": "object", + "description": "Request to create an alternate title for a series", + "required": [ + "label", + "title" + ], + "properties": { + "label": { "type": "string", - "description": "Summary (from first book or series)" + "description": "Label for this title (e.g., \"Japanese\", \"Romaji\", \"English\")", + "example": "Japanese" }, - "summaryNumber": { + "title": { "type": "string", - "description": "Summary number (if multiple summaries)" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Tags from all books" + "description": "The alternate title", + "example": "進撃の巨人" } } }, - "KomgaBooksSearchRequestDto": { + "CreateApiKeyRequest": { "type": "object", - "description": "Request DTO for searching/filtering books (POST /api/v1/books/list)", + "description": "Create API key request", + "required": [ + "name" + ], "properties": { - "author": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - }, - "description": "Authors filter" - }, - "condition": { - "description": "Condition object for complex queries (used by Komic for readStatus filtering)" - }, - "deleted": { - "type": [ - "boolean", - "null" - ], - "description": "Deleted filter" - }, - "fullTextSearch": { + "expiresAt": { "type": [ "string", "null" ], - "description": "Full text search query" + "format": "date-time", + "description": "Optional expiration date", + "example": "2025-12-31T23:59:59Z" }, - "libraryId": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - }, - "description": "Library IDs to filter by" + "name": { + "type": "string", + "description": "Name/description for the API key", + "example": "Mobile App Key" }, - "mediaStatus": { + "permissions": { "type": [ "array", "null" @@ -15602,1907 +15014,5264 @@ "items": { "type": "string" }, - "description": "Media status filter" + "description": "Permissions for the API key (array of permission strings)\nIf not provided, uses the user's current permissions" + } + } + }, + "CreateApiKeyResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiKeyDto" }, - "readStatus": { - "type": [ - "array", - "null" + { + "type": "object", + "required": [ + "key" ], - "items": { - "type": "string" - }, - "description": "Read status filter" - }, - "searchTerm": { + "properties": { + "key": { + "type": "string", + "description": "The plaintext API key (only shown once on creation)", + "example": "cdx_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" + } + } + } + ], + "description": "API key creation response (includes plaintext key only on creation)" + }, + "CreateExternalLinkRequest": { + "type": "object", + "description": "Request to create or update an external link for a series", + "required": [ + "sourceName", + "url" + ], + "properties": { + "externalId": { "type": [ "string", "null" ], - "description": "Search term" + "description": "ID on the external source (if available)", + "example": "12345" }, - "seriesId": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - }, - "description": "Series IDs to filter by" + "sourceName": { + "type": "string", + "description": "Source name (e.g., \"myanimelist\", \"anilist\", \"mangadex\")\nWill be normalized to lowercase", + "example": "myanimelist" }, - "tag": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - }, - "description": "Tags filter" + "url": { + "type": "string", + "description": "URL to the external source", + "example": "https://myanimelist.net/manga/12345" } } }, - "KomgaContentRestrictionsDto": { + "CreateExternalRatingRequest": { "type": "object", - "description": "Komga content restrictions DTO", + "description": "Request to create or update an external rating for a series", + "required": [ + "sourceName", + "rating" + ], "properties": { - "ageRestriction": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/KomgaAgeRestrictionDto", - "description": "Age restriction (null means no restriction)" - } - ] + "rating": { + "type": "number", + "format": "double", + "description": "Rating value (0-100)", + "example": 85.5 }, - "labelsAllow": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Labels restriction" + "sourceName": { + "type": "string", + "description": "Source name (e.g., \"myanimelist\", \"anilist\", \"mangabaka\")\nWill be normalized to lowercase", + "example": "myanimelist" }, - "labelsExclude": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Labels to exclude" + "voteCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Number of votes (if available)", + "example": 12500 } } }, - "KomgaLibraryDto": { + "CreateLibraryRequest": { "type": "object", - "description": "Komga library DTO\n\nBased on actual Komic traffic analysis - includes all fields observed in responses.", + "description": "Create library request", "required": [ - "id", "name", - "root" + "path" ], "properties": { - "analyzeDimensions": { - "type": "boolean", - "description": "Whether to analyze page dimensions" + "allowedFormats": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Allowed file formats (e.g., [\"CBZ\", \"CBR\", \"EPUB\"])", + "example": [ + "CBZ", + "CBR", + "EPUB" + ] }, - "convertToCbz": { - "type": "boolean", - "description": "Whether to convert archives to CBZ" + "bookConfig": { + "description": "Book strategy-specific configuration (JSON, mutable after creation)" }, - "emptyTrashAfterScan": { - "type": "boolean", - "description": "Whether to empty trash after scan" + "bookStrategy": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/BookStrategy", + "description": "Book naming strategy (mutable after creation)\nOptions: filename, metadata_first, smart, series_name" + } + ] }, - "hashFiles": { - "type": "boolean", - "description": "Whether to hash files for deduplication" + "defaultReadingDirection": { + "type": [ + "string", + "null" + ], + "description": "Default reading direction for books in this library (ltr, rtl, ttb or webtoon)", + "example": "ltr" }, - "hashKoreader": { - "type": "boolean", - "description": "Whether to hash files for KOReader sync" + "description": { + "type": [ + "string", + "null" + ], + "description": "Optional description", + "example": "My comic book collection" }, - "hashPages": { - "type": "boolean", - "description": "Whether to hash pages" + "excludedPatterns": { + "type": [ + "string", + "null" + ], + "description": "Excluded path patterns (newline-separated, e.g., \".DS_Store\\nThumbs.db\")", + "example": ".DS_Store\nThumbs.db" }, - "id": { + "name": { "type": "string", - "description": "Library unique identifier (UUID as string)" - }, - "importBarcodeIsbn": { - "type": "boolean", - "description": "Whether to import barcode/ISBN" + "description": "Library name", + "example": "Comics" }, - "importComicInfoBook": { - "type": "boolean", - "description": "Whether to import book info from ComicInfo.xml" + "numberConfig": { + "description": "Number strategy-specific configuration (JSON, mutable after creation)" }, - "importComicInfoCollection": { - "type": "boolean", - "description": "Whether to import collection info from ComicInfo.xml" + "numberStrategy": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NumberStrategy", + "description": "Book number strategy (mutable after creation)\nOptions: file_order, metadata, filename, smart" + } + ] }, - "importComicInfoReadList": { - "type": "boolean", - "description": "Whether to import read list from ComicInfo.xml" + "path": { + "type": "string", + "description": "Filesystem path to the library", + "example": "/media/comics" }, - "importComicInfoSeries": { + "scanImmediately": { "type": "boolean", - "description": "Whether to import series info from ComicInfo.xml" + "description": "Scan immediately after creation (not stored in DB)", + "example": true }, - "importComicInfoSeriesAppendVolume": { - "type": "boolean", - "description": "Whether to append volume to series name from ComicInfo" + "scanningConfig": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ScanningConfigDto", + "description": "Scanning configuration" + } + ] }, - "importEpubBook": { - "type": "boolean", - "description": "Whether to import EPUB book metadata" + "seriesConfig": { + "description": "Strategy-specific configuration (JSON, immutable after creation)" }, - "importEpubSeries": { - "type": "boolean", - "description": "Whether to import EPUB series metadata" + "seriesStrategy": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SeriesStrategy", + "description": "Series detection strategy (immutable after creation)\nOptions: series_volume, series_volume_chapter, flat, publisher_hierarchy, calibre, custom" + } + ] + } + } + }, + "CreatePluginRequest": { + "type": "object", + "description": "Request to create a new plugin", + "required": [ + "name", + "displayName", + "command" + ], + "properties": { + "args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Command arguments", + "example": [ + "/opt/codex/plugins/mangabaka/dist/index.js" + ] }, - "importLocalArtwork": { - "type": "boolean", - "description": "Whether to import local artwork" + "command": { + "type": "string", + "description": "Command to spawn the plugin", + "example": "node" }, - "importMylarSeries": { - "type": "boolean", - "description": "Whether to import Mylar series data" + "config": { + "description": "Plugin-specific configuration" }, - "name": { + "credentialDelivery": { "type": "string", - "description": "Library display name" + "description": "How credentials are delivered to the plugin: \"env\", \"init_message\", or \"both\"", + "example": "env" }, - "oneshotsDirectory": { + "credentials": { + "description": "Credentials (will be encrypted before storage)" + }, + "description": { "type": [ "string", "null" ], - "description": "Directory path for oneshots (optional)" - }, - "repairExtensions": { - "type": "boolean", - "description": "Whether to repair file extensions" + "description": "Description of the plugin", + "example": "Fetch manga metadata from MangaBaka (MangaUpdates)" }, - "root": { + "displayName": { "type": "string", - "description": "Root filesystem path" + "description": "Human-readable display name", + "example": "MangaBaka" }, - "scanCbx": { + "enabled": { "type": "boolean", - "description": "Whether to scan CBZ/CBR files" + "description": "Whether to enable immediately", + "example": false }, - "scanDirectoryExclusions": { + "env": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/EnvVarDto" }, - "description": "Directory exclusion patterns" + "description": "Additional environment variables", + "example": { + "LOG_LEVEL": "info" + } }, - "scanEpub": { - "type": "boolean", - "description": "Whether to scan EPUB files" + "libraryIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Library IDs this plugin applies to (empty = all libraries)", + "example": [] }, - "scanForceModifiedTime": { - "type": "boolean", - "description": "Whether to force modified time for scan" + "name": { + "type": "string", + "description": "Unique identifier (alphanumeric with underscores)", + "example": "mangabaka" }, - "scanInterval": { - "type": "string", - "description": "Scan interval (WEEKLY, DAILY, HOURLY, EVERY_6H, EVERY_12H, DISABLED)" + "permissions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "RBAC permissions for metadata writes", + "example": [ + "metadata:write:summary", + "metadata:write:genres" + ] }, - "scanOnStartup": { - "type": "boolean", - "description": "Whether to scan on startup" + "pluginType": { + "type": "string", + "description": "Plugin type: \"system\" (default) or \"user\"", + "example": "system" }, - "scanPdf": { - "type": "boolean", - "description": "Whether to scan PDF files" + "rateLimitRequestsPerMinute": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Rate limit in requests per minute (default: 60, None = no limit)", + "example": 60 }, - "seriesCover": { - "type": "string", - "description": "Series cover selection strategy (FIRST, FIRST_UNREAD_OR_FIRST, FIRST_UNREAD_OR_LAST, LAST)" + "scopes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Scopes where plugin can be invoked", + "example": [ + "series:detail", + "series:bulk" + ] }, - "unavailable": { - "type": "boolean", - "description": "Whether library is unavailable (path doesn't exist)" + "workingDirectory": { + "type": [ + "string", + "null" + ], + "description": "Working directory for the plugin process" } } }, - "KomgaMediaDto": { + "CreateSharingTagRequest": { "type": "object", - "description": "Komga media DTO\n\nInformation about the book's media/file.", + "description": "Create sharing tag request", "required": [ - "status", - "mediaType", - "mediaProfile", - "pagesCount" + "name" ], "properties": { - "comment": { - "type": "string", - "description": "Comment/notes about media analysis" + "description": { + "type": [ + "string", + "null" + ], + "description": "Optional description", + "example": "Content appropriate for children" }, - "epubDivinaCompatible": { - "type": "boolean", - "description": "Whether EPUB is DIVINA-compatible" + "name": { + "type": "string", + "description": "Display name for the sharing tag (must be unique)", + "example": "Kids Content" + } + } + }, + "CreateTaskRequest": { + "type": "object", + "required": [ + "task_type" + ], + "properties": { + "priority": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Priority level (higher = more urgent)", + "example": 0 }, - "epubIsKepub": { - "type": "boolean", - "description": "Whether EPUB is a KePub file" + "scheduled_for": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When to run the task (defaults to now)", + "example": "2024-01-15T12:00:00Z" }, - "mediaProfile": { + "task_type": { + "$ref": "#/components/schemas/TaskType", + "description": "Type of task to create" + } + } + }, + "CreateTaskResponse": { + "type": "object", + "required": [ + "task_id" + ], + "properties": { + "task_id": { "type": "string", - "description": "Media profile (DIVINA for comics/manga, PDF for PDFs)" + "format": "uuid", + "description": "ID of the created task", + "example": "550e8400-e29b-41d4-a716-446655440000" + } + } + }, + "CreateUserRequest": { + "type": "object", + "description": "Create user request", + "required": [ + "username", + "email", + "password" + ], + "properties": { + "email": { + "type": "string", + "description": "Email address for the new account", + "example": "newuser@example.com" }, - "mediaType": { + "password": { "type": "string", - "description": "MIME type (e.g., \"application/zip\", \"application/epub+zip\", \"application/pdf\")" + "description": "Password for the new account", + "example": "securePassword123!" }, - "pagesCount": { - "type": "integer", - "format": "int32", - "description": "Number of pages" + "role": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/UserRole", + "description": "User role (reader, maintainer, admin). Defaults to reader if not specified." + } + ] }, - "status": { + "username": { "type": "string", - "description": "Media status (READY, UNKNOWN, ERROR, UNSUPPORTED, OUTDATED)" + "description": "Username for the new account", + "example": "newuser" } } }, - "KomgaPageDto": { + "CredentialFieldDto": { "type": "object", - "description": "Komga page DTO\n\nRepresents a single page within a book.\nBased on actual Komic traffic analysis for GET /api/v1/books/{id}/pages", + "description": "Credential field definition", "required": [ - "fileName", - "mediaType", - "number", - "width", - "height", - "sizeBytes", - "size" + "key", + "label", + "credentialType" ], "properties": { - "fileName": { + "credentialType": { "type": "string", - "description": "Original filename within archive" + "description": "Input type for UI" }, - "height": { - "type": "integer", - "format": "int32", - "description": "Image height in pixels" + "description": { + "type": [ + "string", + "null" + ], + "description": "Description for the user" }, - "mediaType": { + "key": { "type": "string", - "description": "MIME type (e.g., \"image/png\", \"image/jpeg\", \"image/webp\")" + "description": "Credential key (e.g., \"api_key\")" }, - "number": { - "type": "integer", - "format": "int32", - "description": "Page number (1-indexed)" + "label": { + "type": "string", + "description": "Display label (e.g., \"API Key\")" }, - "size": { + "required": { + "type": "boolean", + "description": "Whether this credential is required" + }, + "sensitive": { + "type": "boolean", + "description": "Whether to mask the value in UI" + } + } + }, + "CustomStrategyConfig": { + "type": "object", + "description": "Configuration for custom series strategy\n\nNote: Volume/chapter extraction from filenames is handled by the book strategy,\nnot the series strategy. Use CustomBookConfig for regex-based volume/chapter parsing.", + "required": [ + "pattern" + ], + "properties": { + "pattern": { "type": "string", - "description": "Human-readable file size (e.g., \"2.5 MiB\")" + "description": "Regex pattern with named capture groups for series detection\nSupported groups: publisher, series, book\nExample: \"^(?P[^/]+)/(?P[^/]+)/(?P.+)\\\\.(cbz|cbr|epub|pdf)$\"" }, - "sizeBytes": { - "type": "integer", - "format": "int64", - "description": "Page file size in bytes" + "seriesNameTemplate": { + "type": "string", + "description": "Template for constructing series name from capture groups\nExample: \"{publisher} - {series}\"" + } + } + }, + "DeletePreferenceResponse": { + "type": "object", + "description": "Response after deleting a preference", + "required": [ + "deleted", + "message" + ], + "properties": { + "deleted": { + "type": "boolean", + "description": "Whether a preference was deleted", + "example": true }, - "width": { - "type": "integer", - "format": "int32", - "description": "Image width in pixels" + "message": { + "type": "string", + "description": "Message describing the result", + "example": "Preference 'ui.theme' was reset to default" } } }, - "KomgaPage_KomgaBookDto": { + "DetectedSeriesDto": { "type": "object", - "description": "Komga paginated response wrapper (Spring Data Page format)\n\nThis matches the exact structure Komic expects from Komga.", + "description": "Detected series information for preview", "required": [ - "content", - "pageable", - "totalElements", - "totalPages", - "last", - "number", - "size", - "numberOfElements", - "first", - "empty", - "sort" + "name", + "bookCount", + "sampleBooks" ], "properties": { - "content": { - "type": "array", - "items": { - "type": "object", - "description": "Komga book DTO\n\nBased on actual Komic traffic analysis. This is the main book representation.", - "required": [ - "id", - "seriesId", - "seriesTitle", - "libraryId", - "name", - "url", - "number", - "created", - "lastModified", - "fileLastModified", - "sizeBytes", - "size", - "media", - "metadata" - ], - "properties": { - "created": { - "type": "string", - "description": "Created timestamp (ISO 8601)" - }, - "deleted": { - "type": "boolean", - "description": "Whether book is deleted (soft delete)" - }, - "fileHash": { - "type": "string", - "description": "File hash" - }, - "fileLastModified": { - "type": "string", - "description": "File last modified timestamp (ISO 8601)" - }, - "id": { - "type": "string", - "description": "Book unique identifier (UUID as string)" - }, - "lastModified": { - "type": "string", - "description": "Last modified timestamp (ISO 8601)" - }, - "libraryId": { - "type": "string", - "description": "Library ID" - }, - "media": { - "$ref": "#/components/schemas/KomgaMediaDto", - "description": "Media information" - }, - "metadata": { - "$ref": "#/components/schemas/KomgaBookMetadataDto", - "description": "Book metadata" - }, - "name": { - "type": "string", - "description": "Book filename/name" - }, - "number": { - "type": "integer", - "format": "int32", - "description": "Book number in series" - }, - "oneshot": { - "type": "boolean", - "description": "Whether this is a oneshot" - }, - "readProgress": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/KomgaReadProgressDto", - "description": "User's read progress (null if not started)" - } - ] - }, - "seriesId": { - "type": "string", - "description": "Series ID" - }, - "seriesTitle": { - "type": "string", - "description": "Series title (required by Komic for display)" - }, - "size": { - "type": "string", - "description": "Human-readable file size (e.g., \"869.9 MiB\")" - }, - "sizeBytes": { - "type": "integer", - "format": "int64", - "description": "File size in bytes" - }, - "url": { - "type": "string", - "description": "File URL/path" - } - } - }, - "description": "The content items for this page" + "bookCount": { + "type": "integer", + "description": "Number of books detected", + "minimum": 0 }, - "empty": { - "type": "boolean", - "description": "Whether the page is empty" + "metadata": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DetectedSeriesMetadataDto", + "description": "Metadata extracted during detection" + } + ] }, - "first": { - "type": "boolean", - "description": "Whether this is the first page" + "name": { + "type": "string", + "description": "Series name as detected" }, - "last": { - "type": "boolean", - "description": "Whether this is the last page" + "path": { + "type": [ + "string", + "null" + ], + "description": "Path relative to library root" }, - "number": { - "type": "integer", - "format": "int32", - "description": "Current page number (0-indexed)" + "sampleBooks": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Sample book filenames (first 5)" + } + } + }, + "DetectedSeriesMetadataDto": { + "type": "object", + "description": "Metadata extracted during series detection", + "properties": { + "author": { + "type": [ + "string", + "null" + ], + "description": "Author (for calibre strategy)" }, - "numberOfElements": { - "type": "integer", - "format": "int32", - "description": "Number of elements on this page" + "publisher": { + "type": [ + "string", + "null" + ], + "description": "Publisher (for publisher_hierarchy strategy)" + } + } + }, + "DuplicateGroup": { + "type": "object", + "description": "A group of duplicate books", + "required": [ + "id", + "file_hash", + "book_ids", + "duplicate_count", + "created_at", + "updated_at" + ], + "properties": { + "book_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "List of book IDs that share this hash" }, - "pageable": { - "$ref": "#/components/schemas/KomgaPageable", - "description": "Pageable information" + "created_at": { + "type": "string", + "description": "When the duplicate was first detected", + "example": "2024-01-15T10:30:00Z" }, - "size": { + "duplicate_count": { "type": "integer", "format": "int32", - "description": "Page size" + "description": "Number of duplicate copies found", + "example": 3 }, - "sort": { - "$ref": "#/components/schemas/KomgaSort", - "description": "Sort information" + "file_hash": { + "type": "string", + "description": "SHA-256 hash of the file content", + "example": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, - "totalElements": { - "type": "integer", - "format": "int64", - "description": "Total number of elements across all pages" + "id": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for the duplicate group", + "example": "550e8400-e29b-41d4-a716-446655440000" }, - "totalPages": { - "type": "integer", - "format": "int32", - "description": "Total number of pages" + "updated_at": { + "type": "string", + "description": "When the group was last updated", + "example": "2024-01-15T10:30:00Z" } } }, - "KomgaPage_KomgaSeriesDto": { + "EnqueueAutoMatchRequest": { "type": "object", - "description": "Komga paginated response wrapper (Spring Data Page format)\n\nThis matches the exact structure Komic expects from Komga.", + "description": "Request to enqueue plugin auto-match task for a single series", "required": [ - "content", - "pageable", - "totalElements", - "totalPages", - "last", - "number", - "size", - "numberOfElements", - "first", - "empty", - "sort" + "pluginId" ], "properties": { - "content": { + "pluginId": { + "type": "string", + "format": "uuid", + "description": "Plugin ID to use for matching" + } + } + }, + "EnqueueAutoMatchResponse": { + "type": "object", + "description": "Response after enqueuing auto-match task(s)", + "required": [ + "success", + "tasksEnqueued", + "taskIds", + "message" + ], + "properties": { + "message": { + "type": "string", + "description": "Message" + }, + "success": { + "type": "boolean", + "description": "Whether the operation succeeded" + }, + "taskIds": { "type": "array", "items": { - "type": "object", - "description": "Komga series DTO\n\nBased on actual Komic traffic analysis.", - "required": [ - "id", - "libraryId", - "name", - "url", - "created", - "lastModified", - "fileLastModified", - "booksCount", - "booksReadCount", - "booksUnreadCount", - "booksInProgressCount", - "metadata", - "booksMetadata" - ], - "properties": { - "booksCount": { - "type": "integer", - "format": "int32", - "description": "Total books count" - }, - "booksInProgressCount": { - "type": "integer", - "format": "int32", - "description": "In-progress books count" - }, - "booksMetadata": { - "$ref": "#/components/schemas/KomgaBooksMetadataAggregationDto", - "description": "Aggregated books metadata" - }, - "booksReadCount": { - "type": "integer", - "format": "int32", - "description": "Read books count" - }, - "booksUnreadCount": { - "type": "integer", - "format": "int32", - "description": "Unread books count" - }, - "created": { - "type": "string", - "description": "Created timestamp (ISO 8601)" - }, - "deleted": { - "type": "boolean", - "description": "Whether series is deleted (soft delete)" - }, - "fileLastModified": { - "type": "string", - "description": "File last modified timestamp (ISO 8601)" - }, - "id": { - "type": "string", - "description": "Series unique identifier (UUID as string)" - }, - "lastModified": { - "type": "string", - "description": "Last modified timestamp (ISO 8601)" - }, - "libraryId": { - "type": "string", - "description": "Library ID" - }, - "metadata": { - "$ref": "#/components/schemas/KomgaSeriesMetadataDto", - "description": "Series metadata" - }, - "name": { - "type": "string", - "description": "Series name" - }, - "oneshot": { - "type": "boolean", - "description": "Whether this is a oneshot (single book)" - }, - "url": { - "type": "string", - "description": "File URL/path" - } - } + "type": "string", + "format": "uuid" }, - "description": "The content items for this page" - }, - "empty": { - "type": "boolean", - "description": "Whether the page is empty" - }, - "first": { - "type": "boolean", - "description": "Whether this is the first page" - }, - "last": { - "type": "boolean", - "description": "Whether this is the last page" - }, - "number": { - "type": "integer", - "format": "int32", - "description": "Current page number (0-indexed)" + "description": "Task IDs that were created" }, - "numberOfElements": { - "type": "integer", - "format": "int32", - "description": "Number of elements on this page" - }, - "pageable": { - "$ref": "#/components/schemas/KomgaPageable", - "description": "Pageable information" - }, - "size": { - "type": "integer", - "format": "int32", - "description": "Page size" - }, - "sort": { - "$ref": "#/components/schemas/KomgaSort", - "description": "Sort information" - }, - "totalElements": { - "type": "integer", - "format": "int64", - "description": "Total number of elements across all pages" - }, - "totalPages": { + "tasksEnqueued": { "type": "integer", - "format": "int32", - "description": "Total number of pages" + "description": "Number of tasks enqueued", + "minimum": 0 } } }, - "KomgaPageable": { + "EnqueueBulkAutoMatchRequest": { "type": "object", - "description": "Komga pageable information (Spring Data style)", + "description": "Request to enqueue plugin auto-match tasks for multiple series (bulk)", "required": [ - "pageNumber", - "pageSize", - "sort", - "offset", - "paged", - "unpaged" + "pluginId", + "seriesIds" ], "properties": { - "offset": { - "type": "integer", - "format": "int64", - "description": "Offset from start (page_number * page_size)" - }, - "pageNumber": { - "type": "integer", - "format": "int32", - "description": "Current page number (0-indexed)" - }, - "pageSize": { - "type": "integer", - "format": "int32", - "description": "Page size (number of items per page)" - }, - "paged": { - "type": "boolean", - "description": "Whether the pageable is paged (always true for paginated results)" - }, - "sort": { - "$ref": "#/components/schemas/KomgaSort", - "description": "Sort information" + "pluginId": { + "type": "string", + "format": "uuid", + "description": "Plugin ID to use for matching" }, - "unpaged": { - "type": "boolean", - "description": "Whether the pageable is unpaged (always false for paginated results)" + "seriesIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Series IDs to auto-match" } } }, - "KomgaReadProgressDto": { + "EnqueueLibraryAutoMatchRequest": { "type": "object", - "description": "Komga read progress DTO", + "description": "Request to enqueue plugin auto-match tasks for all series in a library", "required": [ - "page", - "completed", - "created", - "lastModified" + "pluginId" ], "properties": { - "completed": { - "type": "boolean", - "description": "Whether the book is completed" - }, - "created": { + "pluginId": { "type": "string", - "description": "Created timestamp (ISO 8601)" - }, - "deviceId": { - "type": "string", - "description": "Device ID that last updated progress" - }, - "deviceName": { - "type": "string", - "description": "Device name that last updated progress" - }, - "lastModified": { - "type": "string", - "description": "Last modified timestamp (ISO 8601)" - }, - "page": { - "type": "integer", - "format": "int32", - "description": "Current page number (1-indexed)" - }, - "readDate": { - "type": [ - "string", - "null" - ], - "description": "When the book was last read (ISO 8601)" + "format": "uuid", + "description": "Plugin ID to use for matching" } } }, - "KomgaReadProgressUpdateDto": { - "type": "object", - "description": "Request DTO for updating read progress\n\nObserved from actual Komic traffic: `{ \"completed\": false, \"page\": 151 }`", - "properties": { - "completed": { - "type": [ - "boolean", - "null" - ], - "description": "Whether book is completed" - }, - "deviceId": { - "type": [ - "string", - "null" - ], - "description": "Device ID (optional, may be used by some clients)" - }, - "deviceName": { - "type": [ - "string", - "null" - ], - "description": "Device name (optional, may be used by some clients)" + "EntityChangeEvent": { + "allOf": [ + { + "$ref": "#/components/schemas/EntityEvent", + "description": "The specific event that occurred" }, - "page": { - "type": [ - "integer", - "null" + { + "type": "object", + "required": [ + "timestamp" ], - "format": "int32", - "description": "Current page number (1-indexed)" + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "description": "When the event occurred" + }, + "user_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "User who triggered the change (if applicable)" + } + } + } + ], + "description": "Complete entity change event with metadata" + }, + "EntityEvent": { + "oneOf": [ + { + "type": "object", + "description": "A book was created", + "required": [ + "book_id", + "series_id", + "library_id", + "type" + ], + "properties": { + "book_id": { + "type": "string", + "format": "uuid" + }, + "library_id": { + "type": "string", + "format": "uuid" + }, + "series_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "book_created" + ] + } + } + }, + { + "type": "object", + "description": "A book was updated", + "required": [ + "book_id", + "series_id", + "library_id", + "type" + ], + "properties": { + "book_id": { + "type": "string", + "format": "uuid" + }, + "fields": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "library_id": { + "type": "string", + "format": "uuid" + }, + "series_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "book_updated" + ] + } + } + }, + { + "type": "object", + "description": "A book was deleted", + "required": [ + "book_id", + "series_id", + "library_id", + "type" + ], + "properties": { + "book_id": { + "type": "string", + "format": "uuid" + }, + "library_id": { + "type": "string", + "format": "uuid" + }, + "series_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "book_deleted" + ] + } + } + }, + { + "type": "object", + "description": "A series was created", + "required": [ + "series_id", + "library_id", + "type" + ], + "properties": { + "library_id": { + "type": "string", + "format": "uuid" + }, + "series_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "series_created" + ] + } + } + }, + { + "type": "object", + "description": "A series was updated", + "required": [ + "series_id", + "library_id", + "type" + ], + "properties": { + "fields": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "library_id": { + "type": "string", + "format": "uuid" + }, + "series_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "series_updated" + ] + } + } + }, + { + "type": "object", + "description": "A series was deleted", + "required": [ + "series_id", + "library_id", + "type" + ], + "properties": { + "library_id": { + "type": "string", + "format": "uuid" + }, + "series_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "series_deleted" + ] + } + } + }, + { + "type": "object", + "description": "Series metadata was updated by a plugin", + "required": [ + "series_id", + "library_id", + "plugin_id", + "fields_updated", + "type" + ], + "properties": { + "fields_updated": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Fields that were updated" + }, + "library_id": { + "type": "string", + "format": "uuid" + }, + "plugin_id": { + "type": "string", + "format": "uuid", + "description": "Plugin that updated the metadata" + }, + "series_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "series_metadata_updated" + ] + } + } + }, + { + "type": "object", + "description": "Deleted books were purged from a series", + "required": [ + "series_id", + "library_id", + "count", + "type" + ], + "properties": { + "count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "library_id": { + "type": "string", + "format": "uuid" + }, + "series_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "series_bulk_purged" + ] + } + } + }, + { + "type": "object", + "description": "A cover image was updated", + "required": [ + "entity_type", + "entity_id", + "type" + ], + "properties": { + "entity_id": { + "type": "string", + "format": "uuid" + }, + "entity_type": { + "$ref": "#/components/schemas/EntityType" + }, + "library_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "cover_updated" + ] + } + } + }, + { + "type": "object", + "description": "A library was updated", + "required": [ + "library_id", + "type" + ], + "properties": { + "library_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "library_updated" + ] + } + } + }, + { + "type": "object", + "description": "A library was deleted", + "required": [ + "library_id", + "type" + ], + "properties": { + "library_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "library_deleted" + ] + } + } + } + ], + "description": "Specific event types for entity changes" + }, + "EntityType": { + "type": "string", + "description": "Type of entity that was changed", + "enum": [ + "book", + "series", + "library" + ] + }, + "EnvVarDto": { + "type": "object", + "description": "Environment variable key-value pair", + "required": [ + "key", + "value" + ], + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "ErrorGroupDto": { + "type": "object", + "description": "Summary of errors grouped by type", + "required": [ + "errorType", + "label", + "count", + "books" + ], + "properties": { + "books": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookWithErrorsDto" + }, + "description": "Books with this error type (paginated)" + }, + "count": { + "type": "integer", + "format": "int64", + "description": "Number of books with this error type", + "example": 5, + "minimum": 0 + }, + "errorType": { + "$ref": "#/components/schemas/BookErrorTypeDto", + "description": "Error type" + }, + "label": { + "type": "string", + "description": "Human-readable label for this error type", + "example": "Parser Error" + } + } + }, + "ErrorResponse": { + "type": "object", + "required": [ + "error", + "message" + ], + "properties": { + "details": {}, + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + "ExecutePluginRequest": { + "type": "object", + "description": "Request to execute a plugin action", + "required": [ + "action" + ], + "properties": { + "action": { + "$ref": "#/components/schemas/PluginActionRequest", + "description": "The action to execute, tagged by plugin type" + } + } + }, + "ExecutePluginResponse": { + "type": "object", + "description": "Response from executing a plugin method", + "required": [ + "success", + "latencyMs" + ], + "properties": { + "error": { + "type": [ + "string", + "null" + ], + "description": "Error message if failed" + }, + "latencyMs": { + "type": "integer", + "format": "int64", + "description": "Execution time in milliseconds", + "minimum": 0 + }, + "result": { + "description": "Result data (varies by method)" + }, + "success": { + "type": "boolean", + "description": "Whether the execution succeeded" + } + } + }, + "ExternalLinkDto": { + "type": "object", + "description": "External link data transfer object", + "required": [ + "id", + "seriesId", + "sourceName", + "url", + "createdAt", + "updatedAt" + ], + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the link was created", + "example": "2024-01-01T00:00:00Z" + }, + "externalId": { + "type": [ + "string", + "null" + ], + "description": "ID on the external source (if available)", + "example": "12345" + }, + "id": { + "type": "string", + "format": "uuid", + "description": "External link ID", + "example": "550e8400-e29b-41d4-a716-446655440060" + }, + "seriesId": { + "type": "string", + "format": "uuid", + "description": "Series ID", + "example": "550e8400-e29b-41d4-a716-446655440002" + }, + "sourceName": { + "type": "string", + "description": "Source name (e.g., \"myanimelist\", \"anilist\", \"mangadex\")", + "example": "myanimelist" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the link was last updated", + "example": "2024-01-15T10:30:00Z" + }, + "url": { + "type": "string", + "description": "URL to the external source", + "example": "https://myanimelist.net/manga/12345" + } + } + }, + "ExternalLinkListResponse": { + "type": "object", + "description": "Response containing a list of external links", + "required": [ + "links" + ], + "properties": { + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalLinkDto" + }, + "description": "List of external links" + } + } + }, + "ExternalRatingDto": { + "type": "object", + "description": "External rating data transfer object", + "required": [ + "id", + "seriesId", + "sourceName", + "rating", + "fetchedAt", + "createdAt", + "updatedAt" + ], + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the rating record was created", + "example": "2024-01-01T00:00:00Z" + }, + "fetchedAt": { + "type": "string", + "format": "date-time", + "description": "When the rating was last fetched from the source", + "example": "2024-01-15T10:30:00Z" + }, + "id": { + "type": "string", + "format": "uuid", + "description": "External rating ID", + "example": "550e8400-e29b-41d4-a716-446655440050" + }, + "rating": { + "type": "number", + "format": "double", + "description": "Rating value (0-100)", + "example": 85.5 + }, + "seriesId": { + "type": "string", + "format": "uuid", + "description": "Series ID", + "example": "550e8400-e29b-41d4-a716-446655440002" + }, + "sourceName": { + "type": "string", + "description": "Source name (e.g., \"myanimelist\", \"anilist\", \"mangabaka\")", + "example": "myanimelist" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the rating record was last updated", + "example": "2024-01-15T10:30:00Z" + }, + "voteCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Number of votes (if available)", + "example": 12500 + } + } + }, + "ExternalRatingListResponse": { + "type": "object", + "description": "Response containing a list of external ratings", + "required": [ + "ratings" + ], + "properties": { + "ratings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalRatingDto" + }, + "description": "List of external ratings" + } + } + }, + "FeedMetadata": { + "type": "object", + "description": "OPDS 2.0 Feed Metadata\n\nMetadata for navigation and publication feeds.", + "required": [ + "title" + ], + "properties": { + "currentPage": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Current page number (for pagination)" + }, + "itemsPerPage": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Items per page (for pagination)" + }, + "modified": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Last modification date" + }, + "numberOfItems": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Total number of items in the collection (for pagination)" + }, + "subtitle": { + "type": [ + "string", + "null" + ], + "description": "Optional subtitle" + }, + "title": { + "type": "string", + "description": "Title of the feed" + } + } + }, + "FieldApplyStatus": { + "type": "string", + "description": "Status of a field during metadata preview", + "enum": [ + "will_apply", + "locked", + "no_permission", + "unchanged", + "not_provided" + ] + }, + "FieldOperator": { + "oneOf": [ + { + "type": "object", + "description": "Exact match", + "required": [ + "value", + "operator" + ], + "properties": { + "operator": { + "type": "string", + "enum": [ + "is" + ] + }, + "value": { + "type": "string" + } + } + }, + { + "type": "object", + "description": "Not equal", + "required": [ + "value", + "operator" + ], + "properties": { + "operator": { + "type": "string", + "enum": [ + "isNot" + ] + }, + "value": { + "type": "string" + } + } + }, + { + "type": "object", + "description": "Field is null/empty", + "required": [ + "operator" + ], + "properties": { + "operator": { + "type": "string", + "enum": [ + "isNull" + ] + } + } + }, + { + "type": "object", + "description": "Field is not null/empty", + "required": [ + "operator" + ], + "properties": { + "operator": { + "type": "string", + "enum": [ + "isNotNull" + ] + } + } + }, + { + "type": "object", + "description": "String contains (case-insensitive)", + "required": [ + "value", + "operator" + ], + "properties": { + "operator": { + "type": "string", + "enum": [ + "contains" + ] + }, + "value": { + "type": "string" + } + } + }, + { + "type": "object", + "description": "String does not contain (case-insensitive)", + "required": [ + "value", + "operator" + ], + "properties": { + "operator": { + "type": "string", + "enum": [ + "doesNotContain" + ] + }, + "value": { + "type": "string" + } + } + }, + { + "type": "object", + "description": "String starts with (case-insensitive)", + "required": [ + "value", + "operator" + ], + "properties": { + "operator": { + "type": "string", + "enum": [ + "beginsWith" + ] + }, + "value": { + "type": "string" + } + } + }, + { + "type": "object", + "description": "String ends with (case-insensitive)", + "required": [ + "value", + "operator" + ], + "properties": { + "operator": { + "type": "string", + "enum": [ + "endsWith" + ] + }, + "value": { + "type": "string" + } + } + } + ], + "description": "Operators for string and equality comparisons" + }, + "FileSystemEntry": { + "type": "object", + "required": [ + "name", + "path", + "is_directory", + "is_readable" + ], + "properties": { + "is_directory": { + "type": "boolean", + "description": "Whether this is a directory" + }, + "is_readable": { + "type": "boolean", + "description": "Whether the entry is readable" + }, + "name": { + "type": "string", + "description": "Name of the file or directory" + }, + "path": { + "type": "string", + "description": "Full path to the entry" + } + }, + "example": { + "is_directory": true, + "is_readable": true, + "name": "Documents", + "path": "/home/user/Documents" + } + }, + "FlatStrategyConfig": { + "type": "object", + "description": "Configuration for flat scanning strategy", + "properties": { + "filenamePatterns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Regex patterns for extracting series name from filename\nPatterns are tried in order, first match wins" + }, + "requireMetadata": { + "type": "boolean", + "description": "If true, require metadata for series detection (no filename fallback)" + } + } + }, + "ForceRequest": { + "type": "object", + "properties": { + "force": { + "type": "boolean", + "description": "If true, regenerate thumbnails even if they exist. If false (default), only generate missing thumbnails.", + "example": false + } + } + }, + "FullBookResponse": { + "type": "object", + "description": "Full book response including book data and complete metadata with locks", + "required": [ + "id", + "libraryId", + "libraryName", + "seriesId", + "seriesName", + "title", + "filePath", + "fileFormat", + "fileSize", + "fileHash", + "pageCount", + "deleted", + "metadata", + "createdAt", + "updatedAt" + ], + "properties": { + "analysisError": { + "type": [ + "string", + "null" + ], + "description": "Error message if book analysis failed", + "example": "Failed to parse CBZ: invalid archive" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the book was added to the library", + "example": "2024-01-01T00:00:00Z" + }, + "deleted": { + "type": "boolean", + "description": "Whether the book has been soft-deleted", + "example": false + }, + "fileFormat": { + "type": "string", + "description": "File format (cbz, cbr, epub, pdf)", + "example": "cbz" + }, + "fileHash": { + "type": "string", + "description": "File hash for deduplication", + "example": "a1b2c3d4e5f6g7h8i9j0" + }, + "filePath": { + "type": "string", + "description": "Filesystem path to the book file", + "example": "/media/comics/Batman/Batman - Year One 001.cbz" + }, + "fileSize": { + "type": "integer", + "format": "int64", + "description": "File size in bytes", + "example": 52428800 + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Book unique identifier", + "example": "550e8400-e29b-41d4-a716-446655440001" + }, + "libraryId": { + "type": "string", + "format": "uuid", + "description": "Library this book belongs to", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "libraryName": { + "type": "string", + "description": "Name of the library", + "example": "Comics" + }, + "metadata": { + "$ref": "#/components/schemas/BookFullMetadata", + "description": "Complete book metadata with lock states" + }, + "number": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Book number within the series", + "example": 1 + }, + "pageCount": { + "type": "integer", + "format": "int32", + "description": "Number of pages in the book", + "example": 32 + }, + "readProgress": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ReadProgressResponse", + "description": "User's read progress for this book" + } + ] + }, + "readingDirection": { + "type": [ + "string", + "null" + ], + "description": "Effective reading direction (from series metadata, or library default)", + "example": "ltr" + }, + "seriesId": { + "type": "string", + "format": "uuid", + "description": "Series this book belongs to", + "example": "550e8400-e29b-41d4-a716-446655440002" + }, + "seriesName": { + "type": "string", + "description": "Name of the series", + "example": "Batman: Year One" + }, + "title": { + "type": "string", + "description": "Book title (display name)", + "example": "Batman: Year One #1" + }, + "titleSort": { + "type": [ + "string", + "null" + ], + "description": "Title used for sorting", + "example": "batman year one 001" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the book was last updated", + "example": "2024-01-15T10:30:00Z" + } + } + }, + "FullSeriesMetadataResponse": { + "type": "object", + "description": "Full series metadata response including all related data", + "required": [ + "seriesId", + "title", + "locks", + "genres", + "tags", + "alternateTitles", + "externalRatings", + "externalLinks", + "createdAt", + "updatedAt" + ], + "properties": { + "ageRating": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Age rating (e.g., 13, 16, 18)", + "example": 16 + }, + "alternateTitles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AlternateTitleDto" + }, + "description": "Alternate titles for this series" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Timestamps", + "example": "2024-01-01T00:00:00Z" + }, + "customMetadata": { + "type": [ + "object", + "null" + ], + "description": "Custom JSON metadata" + }, + "externalLinks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalLinkDto" + }, + "description": "External links to other sites" + }, + "externalRatings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalRatingDto" + }, + "description": "External ratings from various sources" + }, + "genres": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GenreDto" + }, + "description": "Genres assigned to this series" + }, + "imprint": { + "type": [ + "string", + "null" + ], + "description": "Imprint (sub-publisher)", + "example": "Vertigo" + }, + "language": { + "type": [ + "string", + "null" + ], + "description": "Language (BCP47 format: \"en\", \"ja\", \"ko\")", + "example": "en" + }, + "locks": { + "$ref": "#/components/schemas/MetadataLocks", + "description": "Lock states for all metadata fields" + }, + "publisher": { + "type": [ + "string", + "null" + ], + "description": "Publisher name", + "example": "DC Comics" + }, + "readingDirection": { + "type": [ + "string", + "null" + ], + "description": "Reading direction (ltr, rtl, ttb or webtoon)", + "example": "ltr" + }, + "seriesId": { + "type": "string", + "format": "uuid", + "description": "Series ID", + "example": "550e8400-e29b-41d4-a716-446655440002" + }, + "status": { + "type": [ + "string", + "null" + ], + "description": "Series status (ongoing, ended, hiatus, abandoned, unknown)", + "example": "ended" + }, + "summary": { + "type": [ + "string", + "null" + ], + "description": "Series description/summary", + "example": "The definitive origin story of Batman." + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagDto" + }, + "description": "Tags assigned to this series" + }, + "title": { + "type": "string", + "description": "Series title (usually same as series name)", + "example": "Batman: Year One" + }, + "titleSort": { + "type": [ + "string", + "null" + ], + "description": "Custom sort name for ordering", + "example": "Batman Year One" + }, + "totalBookCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Expected total book count (for ongoing series)", + "example": 4 + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "example": "2024-01-15T10:30:00Z" + }, + "year": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Release year", + "example": 1987 + } + } + }, + "FullSeriesResponse": { + "type": "object", + "description": "Full series response including series data and complete metadata", + "required": [ + "id", + "libraryId", + "libraryName", + "bookCount", + "metadata", + "genres", + "tags", + "alternateTitles", + "externalRatings", + "externalLinks", + "createdAt", + "updatedAt" + ], + "properties": { + "alternateTitles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AlternateTitleDto" + }, + "description": "Alternate titles for this series" + }, + "bookCount": { + "type": "integer", + "format": "int64", + "description": "Total number of books in this series", + "example": 4 + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the series was created", + "example": "2024-01-01T00:00:00Z" + }, + "externalLinks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalLinkDto" + }, + "description": "External links to other sites" + }, + "externalRatings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalRatingDto" + }, + "description": "External ratings from various sources" + }, + "genres": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GenreDto" + }, + "description": "Genres assigned to this series" + }, + "hasCustomCover": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the series has a custom cover uploaded", + "example": false + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Series unique identifier", + "example": "550e8400-e29b-41d4-a716-446655440002" + }, + "libraryId": { + "type": "string", + "format": "uuid", + "description": "Library unique identifier", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "libraryName": { + "type": "string", + "description": "Name of the library this series belongs to", + "example": "Comics" + }, + "metadata": { + "$ref": "#/components/schemas/SeriesFullMetadata", + "description": "Complete series metadata" + }, + "path": { + "type": [ + "string", + "null" + ], + "description": "Filesystem path to the series directory", + "example": "/media/comics/Batman - Year One" + }, + "selectedCoverSource": { + "type": [ + "string", + "null" + ], + "description": "Selected cover source (e.g., \"first_book\", \"custom\")", + "example": "first_book" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagDto" + }, + "description": "Tags assigned to this series" + }, + "unreadCount": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Number of unread books in this series (user-specific)", + "example": 2 + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the series was last updated", + "example": "2024-01-15T10:30:00Z" + } + } + }, + "GenerateBookThumbnailsRequest": { + "type": "object", + "description": "Request body for batch book thumbnail generation", + "properties": { + "force": { + "type": "boolean", + "description": "If true, regenerate all thumbnails even if they exist. If false (default), only generate missing thumbnails.", + "example": false + }, + "library_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Optional: scope to a specific library", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "series_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Optional: scope to a specific series (within library if both provided)", + "example": "550e8400-e29b-41d4-a716-446655440001" + } + } + }, + "GenerateSeriesThumbnailsRequest": { + "type": "object", + "description": "Request body for batch series thumbnail generation", + "properties": { + "force": { + "type": "boolean", + "description": "If true, regenerate all thumbnails even if they exist. If false (default), only generate missing thumbnails.", + "example": false + }, + "library_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Optional: scope to a specific library", + "example": "550e8400-e29b-41d4-a716-446655440000" + } + } + }, + "GenreDto": { + "type": "object", + "description": "Genre data transfer object", + "required": [ + "id", + "name", + "createdAt" + ], + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the genre was created", + "example": "2024-01-01T00:00:00Z" + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Genre ID", + "example": "550e8400-e29b-41d4-a716-446655440010" + }, + "name": { + "type": "string", + "description": "Genre name", + "example": "Action" + }, + "seriesCount": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Number of series with this genre", + "example": 42, + "minimum": 0 + } + } + }, + "GenreListResponse": { + "type": "object", + "description": "Response containing a list of genres", + "required": [ + "genres" + ], + "properties": { + "genres": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GenreDto" + }, + "description": "List of genres" + } + } + }, + "Group": { + "type": "object", + "description": "A group containing navigation or publications\n\nGroups allow organizing multiple collections within a single feed.", + "required": [ + "metadata" + ], + "properties": { + "metadata": { + "$ref": "#/components/schemas/FeedMetadata", + "description": "Group metadata (title required)" + }, + "navigation": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/Opds2Link" + }, + "description": "Navigation links within this group" + }, + "publications": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/Publication" + }, + "description": "Publications within this group" + } + } + }, + "ImageLink": { + "type": "object", + "description": "Image link with optional dimensions\n\nUsed for cover images and thumbnails in publications.", + "required": [ + "href", + "type" + ], + "properties": { + "height": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Height in pixels" + }, + "href": { + "type": "string", + "description": "URL to the image" + }, + "type": { + "type": "string", + "description": "Media type of the image" + }, + "width": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Width in pixels" + } + } + }, + "InitializeSetupRequest": { + "type": "object", + "description": "Initialize setup request - creates first admin user", + "required": [ + "username", + "email", + "password" + ], + "properties": { + "email": { + "type": "string", + "description": "Email address for the first admin user" + }, + "password": { + "type": "string", + "description": "Password for the first admin user" + }, + "username": { + "type": "string", + "description": "Username for the first admin user" + } + } + }, + "InitializeSetupResponse": { + "type": "object", + "description": "Initialize setup response - returns user and JWT token", + "required": [ + "user", + "accessToken", + "tokenType", + "expiresIn", + "message" + ], + "properties": { + "accessToken": { + "type": "string", + "description": "JWT access token" + }, + "expiresIn": { + "type": "integer", + "format": "int64", + "description": "Token expiry in seconds", + "minimum": 0 + }, + "message": { + "type": "string", + "description": "Success message" + }, + "tokenType": { + "type": "string", + "description": "Token type (always \"Bearer\")" + }, + "user": { + "$ref": "#/components/schemas/UserInfo", + "description": "Created user information" + } + } + }, + "KomgaAgeRestrictionDto": { + "type": "object", + "description": "Komga age restriction DTO", + "required": [ + "age", + "restriction" + ], + "properties": { + "age": { + "type": "integer", + "format": "int32", + "description": "Age limit" + }, + "restriction": { + "type": "string", + "description": "Restriction type (ALLOW_ONLY, EXCLUDE)" + } + } + }, + "KomgaAlternateTitleDto": { + "type": "object", + "description": "Komga alternate title DTO", + "required": [ + "label", + "title" + ], + "properties": { + "label": { + "type": "string", + "description": "Title label (e.g., \"Japanese\", \"Romaji\")" + }, + "title": { + "type": "string", + "description": "The alternate title text" + } + } + }, + "KomgaAuthorDto": { + "type": "object", + "description": "Komga author DTO", + "required": [ + "name", + "role" + ], + "properties": { + "name": { + "type": "string", + "description": "Author name" + }, + "role": { + "type": "string", + "description": "Author role (WRITER, PENCILLER, INKER, COLORIST, LETTERER, COVER, EDITOR)" + } + } + }, + "KomgaBookDto": { + "type": "object", + "description": "Komga book DTO\n\nBased on actual Komic traffic analysis. This is the main book representation.", + "required": [ + "id", + "seriesId", + "seriesTitle", + "libraryId", + "name", + "url", + "number", + "created", + "lastModified", + "fileLastModified", + "sizeBytes", + "size", + "media", + "metadata" + ], + "properties": { + "created": { + "type": "string", + "description": "Created timestamp (ISO 8601)" + }, + "deleted": { + "type": "boolean", + "description": "Whether book is deleted (soft delete)" + }, + "fileHash": { + "type": "string", + "description": "File hash" + }, + "fileLastModified": { + "type": "string", + "description": "File last modified timestamp (ISO 8601)" + }, + "id": { + "type": "string", + "description": "Book unique identifier (UUID as string)" + }, + "lastModified": { + "type": "string", + "description": "Last modified timestamp (ISO 8601)" + }, + "libraryId": { + "type": "string", + "description": "Library ID" + }, + "media": { + "$ref": "#/components/schemas/KomgaMediaDto", + "description": "Media information" + }, + "metadata": { + "$ref": "#/components/schemas/KomgaBookMetadataDto", + "description": "Book metadata" + }, + "name": { + "type": "string", + "description": "Book filename/name" + }, + "number": { + "type": "integer", + "format": "int32", + "description": "Book number in series" + }, + "oneshot": { + "type": "boolean", + "description": "Whether this is a oneshot" + }, + "readProgress": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/KomgaReadProgressDto", + "description": "User's read progress (null if not started)" + } + ] + }, + "seriesId": { + "type": "string", + "description": "Series ID" + }, + "seriesTitle": { + "type": "string", + "description": "Series title (required by Komic for display)" + }, + "size": { + "type": "string", + "description": "Human-readable file size (e.g., \"869.9 MiB\")" + }, + "sizeBytes": { + "type": "integer", + "format": "int64", + "description": "File size in bytes" + }, + "url": { + "type": "string", + "description": "File URL/path" + } + } + }, + "KomgaBookLinkDto": { + "type": "object", + "description": "Komga book link DTO", + "required": [ + "label", + "url" + ], + "properties": { + "label": { + "type": "string", + "description": "Link label" + }, + "url": { + "type": "string", + "description": "Link URL" + } + } + }, + "KomgaBookMetadataDto": { + "type": "object", + "description": "Komga book metadata DTO", + "required": [ + "title", + "number", + "numberSort", + "created", + "lastModified" + ], + "properties": { + "authors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KomgaAuthorDto" + }, + "description": "Authors list" + }, + "authorsLock": { + "type": "boolean", + "description": "Whether authors are locked" + }, + "created": { + "type": "string", + "description": "Created timestamp (ISO 8601)" + }, + "isbn": { + "type": "string", + "description": "ISBN" + }, + "isbnLock": { + "type": "boolean", + "description": "Whether ISBN is locked" + }, + "lastModified": { + "type": "string", + "description": "Last modified timestamp (ISO 8601)" + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KomgaBookLinkDto" + }, + "description": "Links" + }, + "linksLock": { + "type": "boolean", + "description": "Whether links are locked" + }, + "number": { + "type": "string", + "description": "Book number (display string)" + }, + "numberLock": { + "type": "boolean", + "description": "Whether number is locked" + }, + "numberSort": { + "type": "number", + "format": "double", + "description": "Number for sorting (float for chapter ordering)" + }, + "numberSortLock": { + "type": "boolean", + "description": "Whether number_sort is locked" + }, + "releaseDate": { + "type": [ + "string", + "null" + ], + "description": "Release date (YYYY-MM-DD or full ISO 8601)" + }, + "releaseDateLock": { + "type": "boolean", + "description": "Whether release_date is locked" + }, + "summary": { + "type": "string", + "description": "Book summary" + }, + "summaryLock": { + "type": "boolean", + "description": "Whether summary is locked" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags list" + }, + "tagsLock": { + "type": "boolean", + "description": "Whether tags are locked" + }, + "title": { + "type": "string", + "description": "Book title" + }, + "titleLock": { + "type": "boolean", + "description": "Whether title is locked" + } + } + }, + "KomgaBooksMetadataAggregationDto": { + "type": "object", + "description": "Komga books metadata aggregation DTO\n\nAggregated metadata from all books in the series.", + "required": [ + "created", + "lastModified" + ], + "properties": { + "authors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KomgaAuthorDto" + }, + "description": "Authors from all books" + }, + "created": { + "type": "string", + "description": "Created timestamp (ISO 8601)" + }, + "lastModified": { + "type": "string", + "description": "Last modified timestamp (ISO 8601)" + }, + "releaseDate": { + "type": [ + "string", + "null" + ], + "description": "Release date range (earliest)" + }, + "summary": { + "type": "string", + "description": "Summary (from first book or series)" + }, + "summaryNumber": { + "type": "string", + "description": "Summary number (if multiple summaries)" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags from all books" + } + } + }, + "KomgaBooksSearchRequestDto": { + "type": "object", + "description": "Request DTO for searching/filtering books (POST /api/v1/books/list)", + "properties": { + "author": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Authors filter" + }, + "condition": { + "description": "Condition object for complex queries (used by Komic for readStatus filtering)" + }, + "deleted": { + "type": [ + "boolean", + "null" + ], + "description": "Deleted filter" + }, + "fullTextSearch": { + "type": [ + "string", + "null" + ], + "description": "Full text search query" + }, + "libraryId": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Library IDs to filter by" + }, + "mediaStatus": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Media status filter" + }, + "readStatus": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Read status filter" + }, + "searchTerm": { + "type": [ + "string", + "null" + ], + "description": "Search term" + }, + "seriesId": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Series IDs to filter by" + }, + "tag": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Tags filter" + } + } + }, + "KomgaContentRestrictionsDto": { + "type": "object", + "description": "Komga content restrictions DTO", + "properties": { + "ageRestriction": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/KomgaAgeRestrictionDto", + "description": "Age restriction (null means no restriction)" + } + ] + }, + "labelsAllow": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels restriction" + }, + "labelsExclude": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels to exclude" + } + } + }, + "KomgaLibraryDto": { + "type": "object", + "description": "Komga library DTO\n\nBased on actual Komic traffic analysis - includes all fields observed in responses.", + "required": [ + "id", + "name", + "root" + ], + "properties": { + "analyzeDimensions": { + "type": "boolean", + "description": "Whether to analyze page dimensions" + }, + "convertToCbz": { + "type": "boolean", + "description": "Whether to convert archives to CBZ" + }, + "emptyTrashAfterScan": { + "type": "boolean", + "description": "Whether to empty trash after scan" + }, + "hashFiles": { + "type": "boolean", + "description": "Whether to hash files for deduplication" + }, + "hashKoreader": { + "type": "boolean", + "description": "Whether to hash files for KOReader sync" + }, + "hashPages": { + "type": "boolean", + "description": "Whether to hash pages" + }, + "id": { + "type": "string", + "description": "Library unique identifier (UUID as string)" + }, + "importBarcodeIsbn": { + "type": "boolean", + "description": "Whether to import barcode/ISBN" + }, + "importComicInfoBook": { + "type": "boolean", + "description": "Whether to import book info from ComicInfo.xml" + }, + "importComicInfoCollection": { + "type": "boolean", + "description": "Whether to import collection info from ComicInfo.xml" + }, + "importComicInfoReadList": { + "type": "boolean", + "description": "Whether to import read list from ComicInfo.xml" + }, + "importComicInfoSeries": { + "type": "boolean", + "description": "Whether to import series info from ComicInfo.xml" + }, + "importComicInfoSeriesAppendVolume": { + "type": "boolean", + "description": "Whether to append volume to series name from ComicInfo" + }, + "importEpubBook": { + "type": "boolean", + "description": "Whether to import EPUB book metadata" + }, + "importEpubSeries": { + "type": "boolean", + "description": "Whether to import EPUB series metadata" + }, + "importLocalArtwork": { + "type": "boolean", + "description": "Whether to import local artwork" + }, + "importMylarSeries": { + "type": "boolean", + "description": "Whether to import Mylar series data" + }, + "name": { + "type": "string", + "description": "Library display name" + }, + "oneshotsDirectory": { + "type": [ + "string", + "null" + ], + "description": "Directory path for oneshots (optional)" + }, + "repairExtensions": { + "type": "boolean", + "description": "Whether to repair file extensions" + }, + "root": { + "type": "string", + "description": "Root filesystem path" + }, + "scanCbx": { + "type": "boolean", + "description": "Whether to scan CBZ/CBR files" + }, + "scanDirectoryExclusions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Directory exclusion patterns" + }, + "scanEpub": { + "type": "boolean", + "description": "Whether to scan EPUB files" + }, + "scanForceModifiedTime": { + "type": "boolean", + "description": "Whether to force modified time for scan" + }, + "scanInterval": { + "type": "string", + "description": "Scan interval (WEEKLY, DAILY, HOURLY, EVERY_6H, EVERY_12H, DISABLED)" + }, + "scanOnStartup": { + "type": "boolean", + "description": "Whether to scan on startup" + }, + "scanPdf": { + "type": "boolean", + "description": "Whether to scan PDF files" + }, + "seriesCover": { + "type": "string", + "description": "Series cover selection strategy (FIRST, FIRST_UNREAD_OR_FIRST, FIRST_UNREAD_OR_LAST, LAST)" + }, + "unavailable": { + "type": "boolean", + "description": "Whether library is unavailable (path doesn't exist)" + } + } + }, + "KomgaMediaDto": { + "type": "object", + "description": "Komga media DTO\n\nInformation about the book's media/file.", + "required": [ + "status", + "mediaType", + "mediaProfile", + "pagesCount" + ], + "properties": { + "comment": { + "type": "string", + "description": "Comment/notes about media analysis" + }, + "epubDivinaCompatible": { + "type": "boolean", + "description": "Whether EPUB is DIVINA-compatible" + }, + "epubIsKepub": { + "type": "boolean", + "description": "Whether EPUB is a KePub file" + }, + "mediaProfile": { + "type": "string", + "description": "Media profile (DIVINA for comics/manga, PDF for PDFs)" + }, + "mediaType": { + "type": "string", + "description": "MIME type (e.g., \"application/zip\", \"application/epub+zip\", \"application/pdf\")" + }, + "pagesCount": { + "type": "integer", + "format": "int32", + "description": "Number of pages" + }, + "status": { + "type": "string", + "description": "Media status (READY, UNKNOWN, ERROR, UNSUPPORTED, OUTDATED)" + } + } + }, + "KomgaPageDto": { + "type": "object", + "description": "Komga page DTO\n\nRepresents a single page within a book.\nBased on actual Komic traffic analysis for GET /api/v1/books/{id}/pages", + "required": [ + "fileName", + "mediaType", + "number", + "width", + "height", + "sizeBytes", + "size" + ], + "properties": { + "fileName": { + "type": "string", + "description": "Original filename within archive" + }, + "height": { + "type": "integer", + "format": "int32", + "description": "Image height in pixels" + }, + "mediaType": { + "type": "string", + "description": "MIME type (e.g., \"image/png\", \"image/jpeg\", \"image/webp\")" + }, + "number": { + "type": "integer", + "format": "int32", + "description": "Page number (1-indexed)" + }, + "size": { + "type": "string", + "description": "Human-readable file size (e.g., \"2.5 MiB\")" + }, + "sizeBytes": { + "type": "integer", + "format": "int64", + "description": "Page file size in bytes" + }, + "width": { + "type": "integer", + "format": "int32", + "description": "Image width in pixels" + } + } + }, + "KomgaPage_KomgaBookDto": { + "type": "object", + "description": "Komga paginated response wrapper (Spring Data Page format)\n\nThis matches the exact structure Komic expects from Komga.", + "required": [ + "content", + "pageable", + "totalElements", + "totalPages", + "last", + "number", + "size", + "numberOfElements", + "first", + "empty", + "sort" + ], + "properties": { + "content": { + "type": "array", + "items": { + "type": "object", + "description": "Komga book DTO\n\nBased on actual Komic traffic analysis. This is the main book representation.", + "required": [ + "id", + "seriesId", + "seriesTitle", + "libraryId", + "name", + "url", + "number", + "created", + "lastModified", + "fileLastModified", + "sizeBytes", + "size", + "media", + "metadata" + ], + "properties": { + "created": { + "type": "string", + "description": "Created timestamp (ISO 8601)" + }, + "deleted": { + "type": "boolean", + "description": "Whether book is deleted (soft delete)" + }, + "fileHash": { + "type": "string", + "description": "File hash" + }, + "fileLastModified": { + "type": "string", + "description": "File last modified timestamp (ISO 8601)" + }, + "id": { + "type": "string", + "description": "Book unique identifier (UUID as string)" + }, + "lastModified": { + "type": "string", + "description": "Last modified timestamp (ISO 8601)" + }, + "libraryId": { + "type": "string", + "description": "Library ID" + }, + "media": { + "$ref": "#/components/schemas/KomgaMediaDto", + "description": "Media information" + }, + "metadata": { + "$ref": "#/components/schemas/KomgaBookMetadataDto", + "description": "Book metadata" + }, + "name": { + "type": "string", + "description": "Book filename/name" + }, + "number": { + "type": "integer", + "format": "int32", + "description": "Book number in series" + }, + "oneshot": { + "type": "boolean", + "description": "Whether this is a oneshot" + }, + "readProgress": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/KomgaReadProgressDto", + "description": "User's read progress (null if not started)" + } + ] + }, + "seriesId": { + "type": "string", + "description": "Series ID" + }, + "seriesTitle": { + "type": "string", + "description": "Series title (required by Komic for display)" + }, + "size": { + "type": "string", + "description": "Human-readable file size (e.g., \"869.9 MiB\")" + }, + "sizeBytes": { + "type": "integer", + "format": "int64", + "description": "File size in bytes" + }, + "url": { + "type": "string", + "description": "File URL/path" + } + } + }, + "description": "The content items for this page" + }, + "empty": { + "type": "boolean", + "description": "Whether the page is empty" + }, + "first": { + "type": "boolean", + "description": "Whether this is the first page" + }, + "last": { + "type": "boolean", + "description": "Whether this is the last page" + }, + "number": { + "type": "integer", + "format": "int32", + "description": "Current page number (0-indexed)" + }, + "numberOfElements": { + "type": "integer", + "format": "int32", + "description": "Number of elements on this page" + }, + "pageable": { + "$ref": "#/components/schemas/KomgaPageable", + "description": "Pageable information" + }, + "size": { + "type": "integer", + "format": "int32", + "description": "Page size" + }, + "sort": { + "$ref": "#/components/schemas/KomgaSort", + "description": "Sort information" + }, + "totalElements": { + "type": "integer", + "format": "int64", + "description": "Total number of elements across all pages" + }, + "totalPages": { + "type": "integer", + "format": "int32", + "description": "Total number of pages" + } + } + }, + "KomgaPage_KomgaSeriesDto": { + "type": "object", + "description": "Komga paginated response wrapper (Spring Data Page format)\n\nThis matches the exact structure Komic expects from Komga.", + "required": [ + "content", + "pageable", + "totalElements", + "totalPages", + "last", + "number", + "size", + "numberOfElements", + "first", + "empty", + "sort" + ], + "properties": { + "content": { + "type": "array", + "items": { + "type": "object", + "description": "Komga series DTO\n\nBased on actual Komic traffic analysis.", + "required": [ + "id", + "libraryId", + "name", + "url", + "created", + "lastModified", + "fileLastModified", + "booksCount", + "booksReadCount", + "booksUnreadCount", + "booksInProgressCount", + "metadata", + "booksMetadata" + ], + "properties": { + "booksCount": { + "type": "integer", + "format": "int32", + "description": "Total books count" + }, + "booksInProgressCount": { + "type": "integer", + "format": "int32", + "description": "In-progress books count" + }, + "booksMetadata": { + "$ref": "#/components/schemas/KomgaBooksMetadataAggregationDto", + "description": "Aggregated books metadata" + }, + "booksReadCount": { + "type": "integer", + "format": "int32", + "description": "Read books count" + }, + "booksUnreadCount": { + "type": "integer", + "format": "int32", + "description": "Unread books count" + }, + "created": { + "type": "string", + "description": "Created timestamp (ISO 8601)" + }, + "deleted": { + "type": "boolean", + "description": "Whether series is deleted (soft delete)" + }, + "fileLastModified": { + "type": "string", + "description": "File last modified timestamp (ISO 8601)" + }, + "id": { + "type": "string", + "description": "Series unique identifier (UUID as string)" + }, + "lastModified": { + "type": "string", + "description": "Last modified timestamp (ISO 8601)" + }, + "libraryId": { + "type": "string", + "description": "Library ID" + }, + "metadata": { + "$ref": "#/components/schemas/KomgaSeriesMetadataDto", + "description": "Series metadata" + }, + "name": { + "type": "string", + "description": "Series name" + }, + "oneshot": { + "type": "boolean", + "description": "Whether this is a oneshot (single book)" + }, + "url": { + "type": "string", + "description": "File URL/path" + } + } + }, + "description": "The content items for this page" + }, + "empty": { + "type": "boolean", + "description": "Whether the page is empty" + }, + "first": { + "type": "boolean", + "description": "Whether this is the first page" + }, + "last": { + "type": "boolean", + "description": "Whether this is the last page" + }, + "number": { + "type": "integer", + "format": "int32", + "description": "Current page number (0-indexed)" + }, + "numberOfElements": { + "type": "integer", + "format": "int32", + "description": "Number of elements on this page" + }, + "pageable": { + "$ref": "#/components/schemas/KomgaPageable", + "description": "Pageable information" + }, + "size": { + "type": "integer", + "format": "int32", + "description": "Page size" + }, + "sort": { + "$ref": "#/components/schemas/KomgaSort", + "description": "Sort information" + }, + "totalElements": { + "type": "integer", + "format": "int64", + "description": "Total number of elements across all pages" + }, + "totalPages": { + "type": "integer", + "format": "int32", + "description": "Total number of pages" + } + } + }, + "KomgaPageable": { + "type": "object", + "description": "Komga pageable information (Spring Data style)", + "required": [ + "pageNumber", + "pageSize", + "sort", + "offset", + "paged", + "unpaged" + ], + "properties": { + "offset": { + "type": "integer", + "format": "int64", + "description": "Offset from start (page_number * page_size)" + }, + "pageNumber": { + "type": "integer", + "format": "int32", + "description": "Current page number (0-indexed)" + }, + "pageSize": { + "type": "integer", + "format": "int32", + "description": "Page size (number of items per page)" + }, + "paged": { + "type": "boolean", + "description": "Whether the pageable is paged (always true for paginated results)" + }, + "sort": { + "$ref": "#/components/schemas/KomgaSort", + "description": "Sort information" + }, + "unpaged": { + "type": "boolean", + "description": "Whether the pageable is unpaged (always false for paginated results)" + } + } + }, + "KomgaReadProgressDto": { + "type": "object", + "description": "Komga read progress DTO", + "required": [ + "page", + "completed", + "created", + "lastModified" + ], + "properties": { + "completed": { + "type": "boolean", + "description": "Whether the book is completed" + }, + "created": { + "type": "string", + "description": "Created timestamp (ISO 8601)" + }, + "deviceId": { + "type": "string", + "description": "Device ID that last updated progress" + }, + "deviceName": { + "type": "string", + "description": "Device name that last updated progress" + }, + "lastModified": { + "type": "string", + "description": "Last modified timestamp (ISO 8601)" + }, + "page": { + "type": "integer", + "format": "int32", + "description": "Current page number (1-indexed)" + }, + "readDate": { + "type": [ + "string", + "null" + ], + "description": "When the book was last read (ISO 8601)" + } + } + }, + "KomgaReadProgressUpdateDto": { + "type": "object", + "description": "Request DTO for updating read progress\n\nObserved from actual Komic traffic: `{ \"completed\": false, \"page\": 151 }`", + "properties": { + "completed": { + "type": [ + "boolean", + "null" + ], + "description": "Whether book is completed" + }, + "deviceId": { + "type": [ + "string", + "null" + ], + "description": "Device ID (optional, may be used by some clients)" + }, + "deviceName": { + "type": [ + "string", + "null" + ], + "description": "Device name (optional, may be used by some clients)" + }, + "page": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Current page number (1-indexed)" + } + } + }, + "KomgaSeriesDto": { + "type": "object", + "description": "Komga series DTO\n\nBased on actual Komic traffic analysis.", + "required": [ + "id", + "libraryId", + "name", + "url", + "created", + "lastModified", + "fileLastModified", + "booksCount", + "booksReadCount", + "booksUnreadCount", + "booksInProgressCount", + "metadata", + "booksMetadata" + ], + "properties": { + "booksCount": { + "type": "integer", + "format": "int32", + "description": "Total books count" + }, + "booksInProgressCount": { + "type": "integer", + "format": "int32", + "description": "In-progress books count" + }, + "booksMetadata": { + "$ref": "#/components/schemas/KomgaBooksMetadataAggregationDto", + "description": "Aggregated books metadata" + }, + "booksReadCount": { + "type": "integer", + "format": "int32", + "description": "Read books count" + }, + "booksUnreadCount": { + "type": "integer", + "format": "int32", + "description": "Unread books count" + }, + "created": { + "type": "string", + "description": "Created timestamp (ISO 8601)" + }, + "deleted": { + "type": "boolean", + "description": "Whether series is deleted (soft delete)" + }, + "fileLastModified": { + "type": "string", + "description": "File last modified timestamp (ISO 8601)" + }, + "id": { + "type": "string", + "description": "Series unique identifier (UUID as string)" + }, + "lastModified": { + "type": "string", + "description": "Last modified timestamp (ISO 8601)" + }, + "libraryId": { + "type": "string", + "description": "Library ID" + }, + "metadata": { + "$ref": "#/components/schemas/KomgaSeriesMetadataDto", + "description": "Series metadata" + }, + "name": { + "type": "string", + "description": "Series name" + }, + "oneshot": { + "type": "boolean", + "description": "Whether this is a oneshot (single book)" + }, + "url": { + "type": "string", + "description": "File URL/path" + } + } + }, + "KomgaSeriesMetadataDto": { + "type": "object", + "description": "Komga series metadata DTO", + "required": [ + "status", + "title", + "titleSort", + "created", + "lastModified" + ], + "properties": { + "ageRating": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Age rating" + }, + "ageRatingLock": { + "type": "boolean", + "description": "Whether age_rating is locked" + }, + "alternateTitles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KomgaAlternateTitleDto" + }, + "description": "Alternate titles" + }, + "alternateTitlesLock": { + "type": "boolean", + "description": "Whether alternate_titles are locked" + }, + "created": { + "type": "string", + "description": "Metadata created timestamp (ISO 8601)" + }, + "genres": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Genres list" + }, + "genresLock": { + "type": "boolean", + "description": "Whether genres are locked" + }, + "language": { + "type": "string", + "description": "Language code" + }, + "languageLock": { + "type": "boolean", + "description": "Whether language is locked" + }, + "lastModified": { + "type": "string", + "description": "Metadata last modified timestamp (ISO 8601)" + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KomgaWebLinkDto" + }, + "description": "External links" + }, + "linksLock": { + "type": "boolean", + "description": "Whether links are locked" + }, + "publisher": { + "type": "string", + "description": "Publisher name" + }, + "publisherLock": { + "type": "boolean", + "description": "Whether publisher is locked" + }, + "readingDirection": { + "type": [ + "string", + "null" + ], + "description": "Reading direction (LEFT_TO_RIGHT, RIGHT_TO_LEFT, VERTICAL, WEBTOON)" + }, + "readingDirectionLock": { + "type": "boolean", + "description": "Whether reading_direction is locked" + }, + "sharingLabels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Sharing labels" + }, + "sharingLabelsLock": { + "type": "boolean", + "description": "Whether sharing_labels are locked" + }, + "status": { + "type": "string", + "description": "Series status (ENDED, ONGOING, ABANDONED, HIATUS)" + }, + "statusLock": { + "type": "boolean", + "description": "Whether status is locked" + }, + "summary": { + "type": "string", + "description": "Series summary/description" + }, + "summaryLock": { + "type": "boolean", + "description": "Whether summary is locked" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags list" + }, + "tagsLock": { + "type": "boolean", + "description": "Whether tags are locked" + }, + "title": { + "type": "string", + "description": "Series title" + }, + "titleLock": { + "type": "boolean", + "description": "Whether title is locked" + }, + "titleSort": { + "type": "string", + "description": "Sort title" + }, + "titleSortLock": { + "type": "boolean", + "description": "Whether title_sort is locked" + }, + "totalBookCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Total book count (expected)" + }, + "totalBookCountLock": { + "type": "boolean", + "description": "Whether total_book_count is locked" + } + } + }, + "KomgaSort": { + "type": "object", + "description": "Komga pagination sort information", + "required": [ + "sorted", + "unsorted", + "empty" + ], + "properties": { + "empty": { + "type": "boolean", + "description": "Whether the sort is empty" + }, + "sorted": { + "type": "boolean", + "description": "Whether the results are sorted in ascending or descending order" + }, + "unsorted": { + "type": "boolean", + "description": "Whether the results are unsorted" + } + } + }, + "KomgaUserDto": { + "type": "object", + "description": "Komga user DTO\n\nResponse for GET /api/v1/users/me", + "required": [ + "id", + "email", + "roles" + ], + "properties": { + "contentRestrictions": { + "$ref": "#/components/schemas/KomgaContentRestrictionsDto", + "description": "User's content restrictions" + }, + "email": { + "type": "string", + "description": "User email address" + }, + "id": { + "type": "string", + "description": "User unique identifier (UUID as string)" + }, + "labelsAllow": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Whether user can share content" + }, + "labelsExclude": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels to exclude from sharing" + }, + "roles": { + "type": "array", + "items": { + "type": "string" + }, + "description": "User roles (e.g., [\"ADMIN\"], [\"USER\"])" + }, + "sharedAllLibraries": { + "type": "boolean", + "description": "Whether all libraries are shared with this user" + }, + "sharedLibrariesIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Shared libraries access - list of library IDs user can access\nEmpty means access to all libraries" } } }, - "KomgaSeriesDto": { + "KomgaWebLinkDto": { "type": "object", - "description": "Komga series DTO\n\nBased on actual Komic traffic analysis.", + "description": "Komga web link DTO", + "required": [ + "label", + "url" + ], + "properties": { + "label": { + "type": "string", + "description": "Link label" + }, + "url": { + "type": "string", + "description": "Link URL" + } + } + }, + "LibraryDto": { + "type": "object", + "description": "Library data transfer object", "required": [ "id", - "libraryId", "name", - "url", - "created", - "lastModified", - "fileLastModified", - "booksCount", - "booksReadCount", - "booksUnreadCount", - "booksInProgressCount", - "metadata", - "booksMetadata" + "path", + "isActive", + "seriesStrategy", + "bookStrategy", + "numberStrategy", + "createdAt", + "updatedAt", + "defaultReadingDirection" ], "properties": { - "booksCount": { - "type": "integer", - "format": "int32", - "description": "Total books count" + "allowedFormats": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Allowed file formats (e.g., [\"CBZ\", \"CBR\", \"EPUB\"])", + "example": [ + "CBZ", + "CBR", + "PDF" + ] }, - "booksInProgressCount": { - "type": "integer", - "format": "int32", - "description": "In-progress books count" + "bookConfig": { + "description": "Book strategy-specific configuration (JSON)" }, - "booksMetadata": { - "$ref": "#/components/schemas/KomgaBooksMetadataAggregationDto", - "description": "Aggregated books metadata" + "bookCount": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Total number of books in this library", + "example": 1250 }, - "booksReadCount": { - "type": "integer", - "format": "int32", - "description": "Read books count" + "bookStrategy": { + "$ref": "#/components/schemas/BookStrategy", + "description": "Book naming strategy (filename, metadata_first, smart, series_name)" }, - "booksUnreadCount": { - "type": "integer", - "format": "int32", - "description": "Unread books count" + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the library was created", + "example": "2024-01-01T00:00:00Z" }, - "created": { + "defaultReadingDirection": { "type": "string", - "description": "Created timestamp (ISO 8601)" + "description": "Default reading direction for books in this library (ltr, rtl, ttb or webtoon)", + "example": "ltr" }, - "deleted": { - "type": "boolean", - "description": "Whether series is deleted (soft delete)" + "description": { + "type": [ + "string", + "null" + ], + "description": "Optional description", + "example": "My comic book collection" }, - "fileLastModified": { - "type": "string", - "description": "File last modified timestamp (ISO 8601)" + "excludedPatterns": { + "type": [ + "string", + "null" + ], + "description": "Excluded path patterns (newline-separated, e.g., \".DS_Store\\nThumbs.db\")", + "example": ".DS_Store\nThumbs.db" }, "id": { "type": "string", - "description": "Series unique identifier (UUID as string)" + "format": "uuid", + "description": "Library unique identifier", + "example": "550e8400-e29b-41d4-a716-446655440000" }, - "lastModified": { + "isActive": { + "type": "boolean", + "description": "Whether the library is active", + "example": true + }, + "lastScannedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the library was last scanned", + "example": "2024-01-15T10:30:00Z" + }, + "name": { "type": "string", - "description": "Last modified timestamp (ISO 8601)" + "description": "Library name", + "example": "Comics" }, - "libraryId": { + "numberConfig": { + "description": "Number strategy-specific configuration (JSON)" + }, + "numberStrategy": { + "$ref": "#/components/schemas/NumberStrategy", + "description": "Book number strategy (file_order, metadata, filename, smart)" + }, + "path": { "type": "string", - "description": "Library ID" + "description": "Filesystem path to the library root", + "example": "/media/comics" }, - "metadata": { - "$ref": "#/components/schemas/KomgaSeriesMetadataDto", - "description": "Series metadata" + "scanningConfig": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ScanningConfigDto", + "description": "Scanning configuration for scheduled scans" + } + ] }, - "name": { + "seriesConfig": { + "description": "Strategy-specific configuration (JSON)" + }, + "seriesCount": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Total number of series in this library", + "example": 85 + }, + "seriesStrategy": { + "$ref": "#/components/schemas/SeriesStrategy", + "description": "Series detection strategy (series_volume, series_volume_chapter, flat, etc.)" + }, + "updatedAt": { "type": "string", - "description": "Series name" + "format": "date-time", + "description": "When the library was last updated", + "example": "2024-01-15T10:30:00Z" + } + } + }, + "LibraryMetricsDto": { + "type": "object", + "description": "Metrics for a single library", + "required": [ + "id", + "name", + "series_count", + "book_count", + "total_size" + ], + "properties": { + "book_count": { + "type": "integer", + "format": "int64", + "description": "Number of books in this library", + "example": 1200 }, - "oneshot": { - "type": "boolean", - "description": "Whether this is a oneshot (single book)" + "id": { + "type": "string", + "format": "uuid", + "description": "Library ID", + "example": "550e8400-e29b-41d4-a716-446655440000" }, - "url": { + "name": { "type": "string", - "description": "File URL/path" + "description": "Library name", + "example": "Comics" + }, + "series_count": { + "type": "integer", + "format": "int64", + "description": "Number of series in this library", + "example": 45 + }, + "total_size": { + "type": "integer", + "format": "int64", + "description": "Total size of books in bytes (approx. 15GB)", + "example": "15728640000" } } }, - "KomgaSeriesMetadataDto": { + "LinkProperties": { "type": "object", - "description": "Komga series metadata DTO", - "required": [ - "status", - "title", - "titleSort", - "created", - "lastModified" - ], + "description": "Additional properties that can be attached to links", "properties": { - "ageRating": { + "numberOfItems": { "type": [ "integer", "null" ], - "format": "int32", - "description": "Age rating" - }, - "ageRatingLock": { - "type": "boolean", - "description": "Whether age_rating is locked" - }, - "alternateTitles": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KomgaAlternateTitleDto" - }, - "description": "Alternate titles" - }, - "alternateTitlesLock": { - "type": "boolean", - "description": "Whether alternate_titles are locked" - }, - "created": { - "type": "string", - "description": "Metadata created timestamp (ISO 8601)" - }, - "genres": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Genres list" - }, - "genresLock": { - "type": "boolean", - "description": "Whether genres are locked" - }, - "language": { - "type": "string", - "description": "Language code" - }, - "languageLock": { - "type": "boolean", - "description": "Whether language is locked" - }, - "lastModified": { - "type": "string", - "description": "Metadata last modified timestamp (ISO 8601)" - }, - "links": { + "format": "int64", + "description": "Number of items in the linked collection" + } + } + }, + "ListDuplicatesResponse": { + "type": "object", + "description": "Response for listing duplicates", + "required": [ + "duplicates", + "total_groups", + "total_duplicate_books" + ], + "properties": { + "duplicates": { "type": "array", "items": { - "$ref": "#/components/schemas/KomgaWebLinkDto" + "$ref": "#/components/schemas/DuplicateGroup" }, - "description": "External links" - }, - "linksLock": { - "type": "boolean", - "description": "Whether links are locked" - }, - "publisher": { - "type": "string", - "description": "Publisher name" + "description": "List of duplicate groups" }, - "publisherLock": { - "type": "boolean", - "description": "Whether publisher is locked" + "total_duplicate_books": { + "type": "integer", + "description": "Total number of books that are duplicates", + "example": 15, + "minimum": 0 }, - "readingDirection": { + "total_groups": { + "type": "integer", + "description": "Total number of duplicate groups", + "example": 5, + "minimum": 0 + } + } + }, + "ListSettingsQuery": { + "type": "object", + "description": "Query parameters for listing settings", + "properties": { + "category": { "type": [ "string", "null" ], - "description": "Reading direction (LEFT_TO_RIGHT, RIGHT_TO_LEFT, VERTICAL, WEBTOON)" - }, - "readingDirectionLock": { - "type": "boolean", - "description": "Whether reading_direction is locked" - }, - "sharingLabels": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Sharing labels" - }, - "sharingLabelsLock": { - "type": "boolean", - "description": "Whether sharing_labels are locked" - }, - "status": { + "description": "Filter settings by category", + "example": "scanning" + } + } + }, + "LoginRequest": { + "type": "object", + "description": "Login request", + "required": [ + "username", + "password" + ], + "properties": { + "password": { "type": "string", - "description": "Series status (ENDED, ONGOING, ABANDONED, HIATUS)" - }, - "statusLock": { - "type": "boolean", - "description": "Whether status is locked" + "description": "Password", + "example": "password123" }, - "summary": { + "username": { "type": "string", - "description": "Series summary/description" - }, - "summaryLock": { - "type": "boolean", - "description": "Whether summary is locked" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Tags list" - }, - "tagsLock": { - "type": "boolean", - "description": "Whether tags are locked" - }, - "title": { + "description": "Username or email", + "example": "admin" + } + } + }, + "LoginResponse": { + "type": "object", + "description": "Login response with JWT token", + "required": [ + "accessToken", + "tokenType", + "expiresIn", + "user" + ], + "properties": { + "accessToken": { "type": "string", - "description": "Series title" + "description": "JWT access token", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ" }, - "titleLock": { - "type": "boolean", - "description": "Whether title is locked" + "expiresIn": { + "type": "integer", + "format": "int64", + "description": "Token expiry in seconds", + "example": 86400, + "minimum": 0 }, - "titleSort": { + "tokenType": { "type": "string", - "description": "Sort title" - }, - "titleSortLock": { - "type": "boolean", - "description": "Whether title_sort is locked" + "description": "Token type (always \"Bearer\")", + "example": "Bearer" }, - "totalBookCount": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Total book count (expected)" + "user": { + "$ref": "#/components/schemas/UserInfo", + "description": "User information" + } + } + }, + "MarkReadResponse": { + "type": "object", + "description": "Response for bulk mark as read/unread operations", + "required": [ + "count", + "message" + ], + "properties": { + "count": { + "type": "integer", + "description": "Number of books affected", + "example": 5, + "minimum": 0 }, - "totalBookCountLock": { - "type": "boolean", - "description": "Whether total_book_count is locked" + "message": { + "type": "string", + "description": "Message describing the operation", + "example": "Marked 5 books as read" + } + } + }, + "MessageResponse": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string", + "description": "Response message", + "example": "Task 550e8400-e29b-41d4-a716-446655440000 cancelled" } } }, - "KomgaSort": { + "MetadataAction": { + "type": "string", + "description": "Action for metadata plugins", + "enum": [ + "search", + "get", + "match" + ] + }, + "MetadataApplyRequest": { "type": "object", - "description": "Komga pagination sort information", + "description": "Request to apply metadata from a plugin", "required": [ - "sorted", - "unsorted", - "empty" + "pluginId", + "externalId" ], "properties": { - "empty": { - "type": "boolean", - "description": "Whether the sort is empty" + "externalId": { + "type": "string", + "description": "External ID from the plugin's search results" }, - "sorted": { - "type": "boolean", - "description": "Whether the results are sorted in ascending or descending order" + "fields": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Optional list of fields to apply (default: all applicable fields)" }, - "unsorted": { - "type": "boolean", - "description": "Whether the results are unsorted" + "pluginId": { + "type": "string", + "format": "uuid", + "description": "Plugin ID to fetch metadata from" } } }, - "KomgaUserDto": { + "MetadataApplyResponse": { "type": "object", - "description": "Komga user DTO\n\nResponse for GET /api/v1/users/me", + "description": "Response after applying metadata", "required": [ - "id", - "email", - "roles" + "success", + "appliedFields", + "skippedFields", + "message" ], "properties": { - "contentRestrictions": { - "$ref": "#/components/schemas/KomgaContentRestrictionsDto", - "description": "User's content restrictions" - }, - "email": { - "type": "string", - "description": "User email address" - }, - "id": { - "type": "string", - "description": "User unique identifier (UUID as string)" - }, - "labelsAllow": { + "appliedFields": { "type": "array", "items": { "type": "string" }, - "description": "Whether user can share content" + "description": "Fields that were applied" }, - "labelsExclude": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Labels to exclude from sharing" + "message": { + "type": "string", + "description": "Message" }, - "roles": { + "skippedFields": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/SkippedField" }, - "description": "User roles (e.g., [\"ADMIN\"], [\"USER\"])" + "description": "Fields that were skipped (with reasons)" }, - "sharedAllLibraries": { + "success": { "type": "boolean", - "description": "Whether all libraries are shared with this user" - }, - "sharedLibrariesIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Shared libraries access - list of library IDs user can access\nEmpty means access to all libraries" + "description": "Whether the operation succeeded" } } }, - "KomgaWebLinkDto": { + "MetadataAutoMatchRequest": { "type": "object", - "description": "Komga web link DTO", + "description": "Request to auto-match and apply metadata from a plugin", "required": [ - "label", - "url" + "pluginId" ], "properties": { - "label": { + "pluginId": { "type": "string", - "description": "Link label" + "format": "uuid", + "description": "Plugin ID to use for matching" }, - "url": { - "type": "string", - "description": "Link URL" + "query": { + "type": [ + "string", + "null" + ], + "description": "Optional query to use for matching (defaults to series title)" } } }, - "LibraryDto": { + "MetadataAutoMatchResponse": { "type": "object", - "description": "Library data transfer object", + "description": "Response after auto-matching metadata", "required": [ - "id", - "name", - "path", - "isActive", - "seriesStrategy", - "bookStrategy", - "numberStrategy", - "createdAt", - "updatedAt", - "defaultReadingDirection" + "success", + "appliedFields", + "skippedFields", + "message" ], "properties": { - "allowedFormats": { + "appliedFields": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Fields that were applied" + }, + "externalUrl": { "type": [ - "array", + "string", "null" ], + "description": "External URL (link to matched item on provider)" + }, + "matchedResult": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PluginSearchResultDto", + "description": "The search result that was matched" + } + ] + }, + "message": { + "type": "string", + "description": "Message" + }, + "skippedFields": { + "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/SkippedField" }, - "description": "Allowed file formats (e.g., [\"CBZ\", \"CBR\", \"EPUB\"])", - "example": [ - "CBZ", - "CBR", - "PDF" - ] + "description": "Fields that were skipped (with reasons)" }, - "bookConfig": { - "description": "Book strategy-specific configuration (JSON)" + "success": { + "type": "boolean", + "description": "Whether the operation succeeded" + } + } + }, + "MetadataContentType": { + "type": "string", + "description": "Content types that a metadata provider can support", + "enum": [ + "series" + ] + }, + "MetadataFieldPreview": { + "type": "object", + "description": "A single field in the metadata preview", + "required": [ + "field", + "status" + ], + "properties": { + "currentValue": { + "description": "Current value in database" }, - "bookCount": { + "field": { + "type": "string", + "description": "Field name" + }, + "proposedValue": { + "description": "Proposed value from plugin" + }, + "reason": { "type": [ - "integer", + "string", "null" ], - "format": "int64", - "description": "Total number of books in this library", - "example": 1250 + "description": "Human-readable reason for status" }, - "bookStrategy": { - "$ref": "#/components/schemas/BookStrategy", - "description": "Book naming strategy (filename, metadata_first, smart, series_name)" + "status": { + "$ref": "#/components/schemas/FieldApplyStatus", + "description": "Apply status" + } + } + }, + "MetadataLocks": { + "type": "object", + "description": "Lock states for all lockable metadata fields", + "required": [ + "title", + "titleSort", + "summary", + "publisher", + "imprint", + "status", + "ageRating", + "language", + "readingDirection", + "year", + "totalBookCount", + "genres", + "tags", + "customMetadata" + ], + "properties": { + "ageRating": { + "type": "boolean", + "description": "Whether the age_rating field is locked", + "example": false }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the library was created", - "example": "2024-01-01T00:00:00Z" + "customMetadata": { + "type": "boolean", + "description": "Whether the custom_metadata field is locked", + "example": false }, - "defaultReadingDirection": { + "genres": { + "type": "boolean", + "description": "Whether the genres are locked", + "example": false + }, + "imprint": { + "type": "boolean", + "description": "Whether the imprint field is locked", + "example": false + }, + "language": { + "type": "boolean", + "description": "Whether the language field is locked", + "example": false + }, + "publisher": { + "type": "boolean", + "description": "Whether the publisher field is locked", + "example": false + }, + "readingDirection": { + "type": "boolean", + "description": "Whether the reading_direction field is locked", + "example": false + }, + "status": { + "type": "boolean", + "description": "Whether the status field is locked", + "example": false + }, + "summary": { + "type": "boolean", + "description": "Whether the summary field is locked", + "example": true + }, + "tags": { + "type": "boolean", + "description": "Whether the tags are locked", + "example": false + }, + "title": { + "type": "boolean", + "description": "Whether the title field is locked", + "example": false + }, + "titleSort": { + "type": "boolean", + "description": "Whether the title_sort field is locked", + "example": false + }, + "totalBookCount": { + "type": "boolean", + "description": "Whether the total_book_count field is locked", + "example": false + }, + "year": { + "type": "boolean", + "description": "Whether the year field is locked", + "example": false + } + } + }, + "MetadataPreviewRequest": { + "type": "object", + "description": "Request to preview metadata from a plugin", + "required": [ + "pluginId", + "externalId" + ], + "properties": { + "externalId": { "type": "string", - "description": "Default reading direction for books in this library (ltr, rtl, ttb or webtoon)", - "example": "ltr" - }, - "description": { - "type": [ - "string", - "null" - ], - "description": "Optional description", - "example": "My comic book collection" - }, - "excludedPatterns": { - "type": [ - "string", - "null" - ], - "description": "Excluded path patterns (newline-separated, e.g., \".DS_Store\\nThumbs.db\")", - "example": ".DS_Store\nThumbs.db" + "description": "External ID from the plugin's search results" }, - "id": { + "pluginId": { "type": "string", "format": "uuid", - "description": "Library unique identifier", - "example": "550e8400-e29b-41d4-a716-446655440000" - }, - "isActive": { - "type": "boolean", - "description": "Whether the library is active", - "example": true + "description": "Plugin ID to fetch metadata from" + } + } + }, + "MetadataPreviewResponse": { + "type": "object", + "description": "Response containing metadata preview", + "required": [ + "fields", + "summary", + "pluginId", + "pluginName", + "externalId" + ], + "properties": { + "externalId": { + "type": "string", + "description": "External ID used" }, - "lastScannedAt": { + "externalUrl": { "type": [ "string", "null" ], - "format": "date-time", - "description": "When the library was last scanned", - "example": "2024-01-15T10:30:00Z" - }, - "name": { - "type": "string", - "description": "Library name", - "example": "Comics" - }, - "numberConfig": { - "description": "Number strategy-specific configuration (JSON)" + "description": "External URL (link to provider's page)" }, - "numberStrategy": { - "$ref": "#/components/schemas/NumberStrategy", - "description": "Book number strategy (file_order, metadata, filename, smart)" + "fields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetadataFieldPreview" + }, + "description": "Field-by-field preview" }, - "path": { + "pluginId": { "type": "string", - "description": "Filesystem path to the library root", - "example": "/media/comics" + "format": "uuid", + "description": "Plugin that provided the metadata" }, - "scanningConfig": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ScanningConfigDto", - "description": "Scanning configuration for scheduled scans" - } - ] + "pluginName": { + "type": "string", + "description": "Plugin name" }, - "seriesConfig": { - "description": "Strategy-specific configuration (JSON)" + "summary": { + "$ref": "#/components/schemas/PreviewSummary", + "description": "Summary counts" + } + } + }, + "MetricsCleanupResponse": { + "type": "object", + "description": "Response for cleanup operation", + "required": [ + "deleted_count", + "retention_days" + ], + "properties": { + "deleted_count": { + "type": "integer", + "format": "int64", + "description": "Number of metric records deleted", + "example": 500, + "minimum": 0 }, - "seriesCount": { + "oldest_remaining": { "type": [ - "integer", + "string", "null" ], - "format": "int64", - "description": "Total number of series in this library", - "example": 85 - }, - "seriesStrategy": { - "$ref": "#/components/schemas/SeriesStrategy", - "description": "Series detection strategy (series_volume, series_volume_chapter, flat, etc.)" + "format": "date-time", + "description": "Timestamp of oldest remaining record" }, - "updatedAt": { + "retention_days": { "type": "string", - "format": "date-time", - "description": "When the library was last updated", - "example": "2024-01-15T10:30:00Z" + "description": "Current retention setting", + "example": "30" } } }, - "LibraryMetricsDto": { + "MetricsDto": { "type": "object", - "description": "Metrics for a single library", + "description": "Application metrics response", "required": [ - "id", - "name", + "library_count", "series_count", "book_count", - "total_size" + "total_book_size", + "user_count", + "database_size", + "page_count", + "libraries" ], "properties": { "book_count": { "type": "integer", "format": "int64", - "description": "Number of books in this library", - "example": 1200 + "description": "Total number of books across all libraries", + "example": 3500 }, - "id": { - "type": "string", - "format": "uuid", - "description": "Library ID", - "example": "550e8400-e29b-41d4-a716-446655440000" + "database_size": { + "type": "integer", + "format": "int64", + "description": "Database size in bytes (approximate)", + "example": 10485760 }, - "name": { - "type": "string", - "description": "Library name", - "example": "Comics" + "libraries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryMetricsDto" + }, + "description": "Breakdown by library" + }, + "library_count": { + "type": "integer", + "format": "int64", + "description": "Total number of libraries in the system", + "example": 5 + }, + "page_count": { + "type": "integer", + "format": "int64", + "description": "Number of pages across all books", + "example": 175000 }, "series_count": { "type": "integer", "format": "int64", - "description": "Number of series in this library", - "example": 45 + "description": "Total number of series across all libraries", + "example": 150 }, - "total_size": { + "total_book_size": { "type": "integer", "format": "int64", - "description": "Total size of books in bytes (approx. 15GB)", - "example": "15728640000" + "description": "Total size of all books in bytes (approx. 50GB)", + "example": "52428800000" + }, + "user_count": { + "type": "integer", + "format": "int64", + "description": "Number of registered users", + "example": 12 } } }, - "LinkProperties": { + "MetricsNukeResponse": { "type": "object", - "description": "Additional properties that can be attached to links", + "description": "Response for nuke (delete all) operation", + "required": [ + "deleted_count" + ], "properties": { - "numberOfItems": { + "deleted_count": { + "type": "integer", + "format": "int64", + "description": "Number of metric records deleted", + "example": 15000, + "minimum": 0 + } + } + }, + "ModifySeriesSharingTagRequest": { + "type": "object", + "description": "Add/remove single sharing tag from series request", + "required": [ + "sharingTagId" + ], + "properties": { + "sharingTagId": { + "type": "string", + "format": "uuid", + "description": "Sharing tag ID", + "example": "550e8400-e29b-41d4-a716-446655440000" + } + } + }, + "NumberStrategy": { + "type": "string", + "description": "Book number strategy type for determining book ordering numbers\n\nDetermines how individual book numbers are resolved for sorting and display.", + "enum": [ + "file_order", + "metadata", + "filename", + "smart" + ] + }, + "Opds2Feed": { + "type": "object", + "description": "OPDS 2.0 Feed\n\nThe main container for OPDS 2.0 data. A feed contains metadata,\nlinks, and one of: navigation, publications, or groups.", + "required": [ + "metadata", + "links" + ], + "properties": { + "groups": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/Group" + }, + "description": "Groups containing multiple collections" + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Opds2Link" + }, + "description": "Feed-level links (self, search, start, etc.)" + }, + "metadata": { + "$ref": "#/components/schemas/FeedMetadata", + "description": "Feed metadata (title, pagination, etc.)" + }, + "navigation": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/Opds2Link" + }, + "description": "Navigation links (for navigation feeds)" + }, + "publications": { "type": [ - "integer", + "array", "null" ], - "format": "int64", - "description": "Number of items in the linked collection" + "items": { + "$ref": "#/components/schemas/Publication" + }, + "description": "Publication entries (for acquisition feeds)" } } }, - "ListDuplicatesResponse": { + "Opds2Link": { "type": "object", - "description": "Response for listing duplicates", + "description": "OPDS 2.0 Link Object\n\nRepresents a link in an OPDS 2.0 feed, based on the Web Publication Manifest model.\nLinks can be templated using URI templates (RFC 6570).", "required": [ - "duplicates", - "total_groups", - "total_duplicate_books" + "href" ], "properties": { - "duplicates": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DuplicateGroup" - }, - "description": "List of duplicate groups" + "href": { + "type": "string", + "description": "The URI or URI template for the link" }, - "total_duplicate_books": { - "type": "integer", - "description": "Total number of books that are duplicates", - "example": 15, - "minimum": 0 + "properties": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LinkProperties", + "description": "Additional properties for the link" + } + ] }, - "total_groups": { - "type": "integer", - "description": "Total number of duplicate groups", - "example": 5, - "minimum": 0 - } - } - }, - "ListSettingsQuery": { - "type": "object", - "description": "Query parameters for listing settings", - "properties": { - "category": { + "rel": { "type": [ "string", "null" ], - "description": "Filter settings by category", - "example": "scanning" - } - } - }, - "LoginRequest": { - "type": "object", - "description": "Login request", - "required": [ - "username", - "password" - ], - "properties": { - "password": { - "type": "string", - "description": "Password", - "example": "password123" + "description": "Relation type (e.g., \"self\", \"search\", \"http://opds-spec.org/acquisition\")" }, - "username": { - "type": "string", - "description": "Username or email", - "example": "admin" + "templated": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the href is a URI template" + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Human-readable title for the link" + }, + "type": { + "type": [ + "string", + "null" + ], + "description": "Media type of the linked resource" } } }, - "LoginResponse": { + "OrphanStatsDto": { "type": "object", - "description": "Login response with JWT token", + "description": "Statistics about orphaned files in the system", "required": [ - "accessToken", - "tokenType", - "expiresIn", - "user" + "orphaned_thumbnails", + "orphaned_covers", + "total_size_bytes" ], "properties": { - "accessToken": { - "type": "string", - "description": "JWT access token", - "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ" + "files": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/OrphanedFileDto" + }, + "description": "List of orphaned files with details" }, - "expiresIn": { + "orphaned_covers": { "type": "integer", - "format": "int64", - "description": "Token expiry in seconds", - "example": 86400, + "format": "int32", + "description": "Number of orphaned cover files (no matching series in database)", + "example": 5, "minimum": 0 }, - "tokenType": { - "type": "string", - "description": "Token type (always \"Bearer\")", - "example": "Bearer" - }, - "user": { - "$ref": "#/components/schemas/UserInfo", - "description": "User information" - } - } - }, - "MarkReadResponse": { - "type": "object", - "description": "Response for bulk mark as read/unread operations", - "required": [ - "count", - "message" - ], - "properties": { - "count": { + "orphaned_thumbnails": { "type": "integer", - "description": "Number of books affected", - "example": 5, + "format": "int32", + "description": "Number of orphaned thumbnail files (no matching book in database)", + "example": 42, "minimum": 0 }, - "message": { - "type": "string", - "description": "Message describing the operation", - "example": "Marked 5 books as read" + "total_size_bytes": { + "type": "integer", + "format": "int64", + "description": "Total size of all orphaned files in bytes", + "example": 1073741824, + "minimum": 0 } } }, - "MessageResponse": { + "OrphanStatsQuery": { "type": "object", - "required": [ - "message" - ], + "description": "Query parameters for orphan stats endpoint", "properties": { - "message": { - "type": "string", - "description": "Response message", - "example": "Task 550e8400-e29b-41d4-a716-446655440000 cancelled" + "includeFiles": { + "type": "boolean", + "description": "If true, include the full list of orphaned files in the response" } } }, - "MetadataLocks": { + "OrphanedFileDto": { "type": "object", - "description": "Lock states for all lockable metadata fields", + "description": "Information about a single orphaned file", "required": [ - "title", - "titleSort", - "summary", - "publisher", - "imprint", - "status", - "ageRating", - "language", - "readingDirection", - "year", - "totalBookCount", - "genres", - "tags", - "customMetadata" + "path", + "size_bytes", + "file_type" ], "properties": { - "ageRating": { - "type": "boolean", - "description": "Whether the age_rating field is locked", - "example": false - }, - "customMetadata": { - "type": "boolean", - "description": "Whether the custom_metadata field is locked", - "example": false - }, - "genres": { - "type": "boolean", - "description": "Whether the genres are locked", - "example": false - }, - "imprint": { - "type": "boolean", - "description": "Whether the imprint field is locked", - "example": false - }, - "language": { - "type": "boolean", - "description": "Whether the language field is locked", - "example": false - }, - "publisher": { - "type": "boolean", - "description": "Whether the publisher field is locked", - "example": false - }, - "readingDirection": { - "type": "boolean", - "description": "Whether the reading_direction field is locked", - "example": false - }, - "status": { - "type": "boolean", - "description": "Whether the status field is locked", - "example": false - }, - "summary": { - "type": "boolean", - "description": "Whether the summary field is locked", - "example": true - }, - "tags": { - "type": "boolean", - "description": "Whether the tags are locked", - "example": false - }, - "title": { - "type": "boolean", - "description": "Whether the title field is locked", - "example": false + "entity_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "The entity UUID extracted from the filename", + "example": "550e8400-e29b-41d4-a716-446655440000" }, - "titleSort": { - "type": "boolean", - "description": "Whether the title_sort field is locked", - "example": false + "file_type": { + "type": "string", + "description": "Type of file: \"thumbnail\" or \"cover\"", + "example": "thumbnail" }, - "totalBookCount": { - "type": "boolean", - "description": "Whether the total_book_count field is locked", - "example": false + "path": { + "type": "string", + "description": "Path to the orphaned file (relative to data directory)", + "example": "thumbnails/books/55/550e8400-e29b-41d4-a716-446655440000.jpg" }, - "year": { - "type": "boolean", - "description": "Whether the year field is locked", - "example": false + "size_bytes": { + "type": "integer", + "format": "int64", + "description": "Size of the file in bytes", + "example": 25600, + "minimum": 0 } } }, - "MetricsCleanupResponse": { + "PageDto": { "type": "object", - "description": "Response for cleanup operation", + "description": "Page data transfer object", "required": [ - "deleted_count", - "retention_days" + "id", + "bookId", + "pageNumber", + "fileName", + "fileFormat", + "fileSize" ], "properties": { - "deleted_count": { + "bookId": { + "type": "string", + "format": "uuid", + "description": "Book this page belongs to", + "example": "550e8400-e29b-41d4-a716-446655440001" + }, + "fileFormat": { + "type": "string", + "description": "Image format (jpg, png, webp, etc.)", + "example": "jpg" + }, + "fileName": { + "type": "string", + "description": "Original filename within the archive", + "example": "page_001.jpg" + }, + "fileSize": { "type": "integer", "format": "int64", - "description": "Number of metric records deleted", - "example": 500, - "minimum": 0 + "description": "File size in bytes", + "example": 524288 }, - "oldest_remaining": { + "height": { "type": [ - "string", + "integer", "null" ], - "format": "date-time", - "description": "Timestamp of oldest remaining record" + "format": "int32", + "description": "Image height in pixels", + "example": 1800 }, - "retention_days": { + "id": { "type": "string", - "description": "Current retention setting", - "example": "30" + "format": "uuid", + "description": "Unique page identifier", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "pageNumber": { + "type": "integer", + "format": "int32", + "description": "Page number within the book (0-indexed)", + "example": 0 + }, + "width": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Image width in pixels", + "example": 1200 } } }, - "MetricsDto": { + "PaginatedResponse": { "type": "object", - "description": "Application metrics response", + "description": "Generic paginated response wrapper with HATEOAS links", "required": [ - "library_count", - "series_count", - "book_count", - "total_book_size", - "user_count", - "database_size", - "page_count", - "libraries" + "data", + "page", + "pageSize", + "total", + "totalPages", + "links" ], "properties": { - "book_count": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookDto" + }, + "description": "The data items for this page" + }, + "links": { + "$ref": "#/components/schemas/PaginationLinks", + "description": "HATEOAS navigation links" + }, + "page": { "type": "integer", "format": "int64", - "description": "Total number of books across all libraries", - "example": 3500 + "description": "Current page number (1-indexed)", + "example": 1, + "minimum": 0 }, - "database_size": { + "pageSize": { "type": "integer", "format": "int64", - "description": "Database size in bytes (approximate)", - "example": 10485760 - }, - "libraries": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LibraryMetricsDto" - }, - "description": "Breakdown by library" + "description": "Number of items per page", + "example": 50, + "minimum": 0 }, - "library_count": { + "total": { "type": "integer", "format": "int64", - "description": "Total number of libraries in the system", - "example": 5 + "description": "Total number of items across all pages", + "example": 150, + "minimum": 0 }, - "page_count": { + "totalPages": { "type": "integer", "format": "int64", - "description": "Number of pages across all books", - "example": 175000 + "description": "Total number of pages", + "example": 3, + "minimum": 0 + } + } + }, + "PaginatedResponse_ApiKeyDto": { + "type": "object", + "description": "Generic paginated response wrapper with HATEOAS links", + "required": [ + "data", + "page", + "pageSize", + "total", + "totalPages", + "links" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "description": "API key data transfer object", + "required": [ + "id", + "userId", + "name", + "keyPrefix", + "permissions", + "isActive", + "createdAt", + "updatedAt" + ], + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the key was created", + "example": "2024-01-01T00:00:00Z" + }, + "expiresAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the key expires (if set)", + "example": "2025-12-31T23:59:59Z" + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Unique API key identifier", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "isActive": { + "type": "boolean", + "description": "Whether the key is currently active", + "example": true + }, + "keyPrefix": { + "type": "string", + "description": "Prefix of the key for identification", + "example": "cdx_a1b2c3" + }, + "lastUsedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the key was last used", + "example": "2024-01-15T10:30:00Z" + }, + "name": { + "type": "string", + "description": "Human-readable name for the key", + "example": "Mobile App Key" + }, + "permissions": { + "description": "Permissions granted to this key" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the key was last updated", + "example": "2024-01-15T10:30:00Z" + }, + "userId": { + "type": "string", + "format": "uuid", + "description": "Owner user ID", + "example": "550e8400-e29b-41d4-a716-446655440001" + } + } + }, + "description": "The data items for this page" + }, + "links": { + "$ref": "#/components/schemas/PaginationLinks", + "description": "HATEOAS navigation links" }, - "series_count": { + "page": { "type": "integer", "format": "int64", - "description": "Total number of series across all libraries", - "example": 150 + "description": "Current page number (1-indexed)", + "example": 1, + "minimum": 0 }, - "total_book_size": { + "pageSize": { "type": "integer", "format": "int64", - "description": "Total size of all books in bytes (approx. 50GB)", - "example": "52428800000" + "description": "Number of items per page", + "example": 50, + "minimum": 0 }, - "user_count": { + "total": { "type": "integer", "format": "int64", - "description": "Number of registered users", - "example": 12 - } - } - }, - "MetricsNukeResponse": { - "type": "object", - "description": "Response for nuke (delete all) operation", - "required": [ - "deleted_count" - ], - "properties": { - "deleted_count": { + "description": "Total number of items across all pages", + "example": 150, + "minimum": 0 + }, + "totalPages": { "type": "integer", "format": "int64", - "description": "Number of metric records deleted", - "example": 15000, + "description": "Total number of pages", + "example": 3, "minimum": 0 } } }, - "ModifySeriesSharingTagRequest": { - "type": "object", - "description": "Add/remove single sharing tag from series request", - "required": [ - "sharingTagId" - ], - "properties": { - "sharingTagId": { - "type": "string", - "format": "uuid", - "description": "Sharing tag ID", - "example": "550e8400-e29b-41d4-a716-446655440000" - } - } - }, - "NumberStrategy": { - "type": "string", - "description": "Book number strategy type for determining book ordering numbers\n\nDetermines how individual book numbers are resolved for sorting and display.", - "enum": [ - "file_order", - "metadata", - "filename", - "smart" - ] - }, - "Opds2Feed": { + "PaginatedResponse_BookDto": { "type": "object", - "description": "OPDS 2.0 Feed\n\nThe main container for OPDS 2.0 data. A feed contains metadata,\nlinks, and one of: navigation, publications, or groups.", + "description": "Generic paginated response wrapper with HATEOAS links", "required": [ - "metadata", + "data", + "page", + "pageSize", + "total", + "totalPages", "links" ], "properties": { - "groups": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/components/schemas/Group" - }, - "description": "Groups containing multiple collections" - }, - "links": { + "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Opds2Link" - }, - "description": "Feed-level links (self, search, start, etc.)" - }, - "metadata": { - "$ref": "#/components/schemas/FeedMetadata", - "description": "Feed metadata (title, pagination, etc.)" - }, - "navigation": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/components/schemas/Opds2Link" - }, - "description": "Navigation links (for navigation feeds)" - }, - "publications": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/components/schemas/Publication" - }, - "description": "Publication entries (for acquisition feeds)" - } - } - }, - "Opds2Link": { - "type": "object", - "description": "OPDS 2.0 Link Object\n\nRepresents a link in an OPDS 2.0 feed, based on the Web Publication Manifest model.\nLinks can be templated using URI templates (RFC 6570).", - "required": [ - "href" - ], - "properties": { - "href": { - "type": "string", - "description": "The URI or URI template for the link" - }, - "properties": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/LinkProperties", - "description": "Additional properties for the link" + "type": "object", + "description": "Book data transfer object", + "required": [ + "id", + "libraryId", + "libraryName", + "seriesId", + "seriesName", + "title", + "filePath", + "fileFormat", + "fileSize", + "fileHash", + "pageCount", + "createdAt", + "updatedAt", + "deleted" + ], + "properties": { + "analysisError": { + "type": [ + "string", + "null" + ], + "description": "Error message if book analysis failed", + "example": "Failed to parse CBZ: invalid archive" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the book was added to the library", + "example": "2024-01-01T00:00:00Z" + }, + "deleted": { + "type": "boolean", + "description": "Whether the book has been soft-deleted", + "example": false + }, + "fileFormat": { + "type": "string", + "description": "File format (cbz, cbr, epub, pdf)", + "example": "cbz" + }, + "fileHash": { + "type": "string", + "description": "File hash for deduplication", + "example": "a1b2c3d4e5f6g7h8i9j0" + }, + "filePath": { + "type": "string", + "description": "Filesystem path to the book file", + "example": "/media/comics/Batman/Batman - Year One 001.cbz" + }, + "fileSize": { + "type": "integer", + "format": "int64", + "description": "File size in bytes", + "example": 52428800 + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Book unique identifier", + "example": "550e8400-e29b-41d4-a716-446655440001" + }, + "libraryId": { + "type": "string", + "format": "uuid", + "description": "Library this book belongs to", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "libraryName": { + "type": "string", + "description": "Name of the library", + "example": "Comics" + }, + "number": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Book number within the series", + "example": 1 + }, + "pageCount": { + "type": "integer", + "format": "int32", + "description": "Number of pages in the book", + "example": 32 + }, + "readProgress": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ReadProgressResponse", + "description": "User's read progress for this book" + } + ] + }, + "readingDirection": { + "type": [ + "string", + "null" + ], + "description": "Effective reading direction (from series metadata, or library default if not set)\nValues: ltr, rtl, ttb or webtoon", + "example": "ltr" + }, + "seriesId": { + "type": "string", + "format": "uuid", + "description": "Series this book belongs to", + "example": "550e8400-e29b-41d4-a716-446655440002" + }, + "seriesName": { + "type": "string", + "description": "Name of the series", + "example": "Batman: Year One" + }, + "title": { + "type": "string", + "description": "Book title", + "example": "Batman: Year One #1" + }, + "titleSort": { + "type": [ + "string", + "null" + ], + "description": "Title used for sorting (title_sort field)", + "example": "batman year one 001" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the book was last updated", + "example": "2024-01-15T10:30:00Z" + } } - ] - }, - "rel": { - "type": [ - "string", - "null" - ], - "description": "Relation type (e.g., \"self\", \"search\", \"http://opds-spec.org/acquisition\")" - }, - "templated": { - "type": [ - "boolean", - "null" - ], - "description": "Whether the href is a URI template" - }, - "title": { - "type": [ - "string", - "null" - ], - "description": "Human-readable title for the link" - }, - "type": { - "type": [ - "string", - "null" - ], - "description": "Media type of the linked resource" - } - } - }, - "OrphanStatsDto": { - "type": "object", - "description": "Statistics about orphaned files in the system", - "required": [ - "orphaned_thumbnails", - "orphaned_covers", - "total_size_bytes" - ], - "properties": { - "files": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/components/schemas/OrphanedFileDto" }, - "description": "List of orphaned files with details" + "description": "The data items for this page" }, - "orphaned_covers": { + "links": { + "$ref": "#/components/schemas/PaginationLinks", + "description": "HATEOAS navigation links" + }, + "page": { "type": "integer", - "format": "int32", - "description": "Number of orphaned cover files (no matching series in database)", - "example": 5, + "format": "int64", + "description": "Current page number (1-indexed)", + "example": 1, "minimum": 0 }, - "orphaned_thumbnails": { + "pageSize": { "type": "integer", - "format": "int32", - "description": "Number of orphaned thumbnail files (no matching book in database)", - "example": 42, + "format": "int64", + "description": "Number of items per page", + "example": 50, "minimum": 0 }, - "total_size_bytes": { + "total": { "type": "integer", "format": "int64", - "description": "Total size of all orphaned files in bytes", - "example": 1073741824, + "description": "Total number of items across all pages", + "example": 150, "minimum": 0 - } - } - }, - "OrphanStatsQuery": { - "type": "object", - "description": "Query parameters for orphan stats endpoint", - "properties": { - "includeFiles": { - "type": "boolean", - "description": "If true, include the full list of orphaned files in the response" - } - } - }, - "OrphanedFileDto": { - "type": "object", - "description": "Information about a single orphaned file", - "required": [ - "path", - "size_bytes", - "file_type" - ], - "properties": { - "entity_id": { - "type": [ - "string", - "null" - ], - "format": "uuid", - "description": "The entity UUID extracted from the filename", - "example": "550e8400-e29b-41d4-a716-446655440000" - }, - "file_type": { - "type": "string", - "description": "Type of file: \"thumbnail\" or \"cover\"", - "example": "thumbnail" - }, - "path": { - "type": "string", - "description": "Path to the orphaned file (relative to data directory)", - "example": "thumbnails/books/55/550e8400-e29b-41d4-a716-446655440000.jpg" }, - "size_bytes": { + "totalPages": { "type": "integer", "format": "int64", - "description": "Size of the file in bytes", - "example": 25600, + "description": "Total number of pages", + "example": 3, "minimum": 0 } } }, - "PageDto": { + "PaginatedResponse_GenreDto": { "type": "object", - "description": "Page data transfer object", + "description": "Generic paginated response wrapper with HATEOAS links", "required": [ - "id", - "bookId", - "pageNumber", - "fileName", - "fileFormat", - "fileSize" + "data", + "page", + "pageSize", + "total", + "totalPages", + "links" ], "properties": { - "bookId": { - "type": "string", - "format": "uuid", - "description": "Book this page belongs to", - "example": "550e8400-e29b-41d4-a716-446655440001" - }, - "fileFormat": { - "type": "string", - "description": "Image format (jpg, png, webp, etc.)", - "example": "jpg" + "data": { + "type": "array", + "items": { + "type": "object", + "description": "Genre data transfer object", + "required": [ + "id", + "name", + "createdAt" + ], + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the genre was created", + "example": "2024-01-01T00:00:00Z" + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Genre ID", + "example": "550e8400-e29b-41d4-a716-446655440010" + }, + "name": { + "type": "string", + "description": "Genre name", + "example": "Action" + }, + "seriesCount": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Number of series with this genre", + "example": 42, + "minimum": 0 + } + } + }, + "description": "The data items for this page" }, - "fileName": { - "type": "string", - "description": "Original filename within the archive", - "example": "page_001.jpg" + "links": { + "$ref": "#/components/schemas/PaginationLinks", + "description": "HATEOAS navigation links" }, - "fileSize": { + "page": { "type": "integer", "format": "int64", - "description": "File size in bytes", - "example": 524288 - }, - "height": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Image height in pixels", - "example": 1800 - }, - "id": { - "type": "string", - "format": "uuid", - "description": "Unique page identifier", - "example": "550e8400-e29b-41d4-a716-446655440000" + "description": "Current page number (1-indexed)", + "example": 1, + "minimum": 0 }, - "pageNumber": { + "pageSize": { "type": "integer", - "format": "int32", - "description": "Page number within the book (0-indexed)", - "example": 0 + "format": "int64", + "description": "Number of items per page", + "example": 50, + "minimum": 0 }, - "width": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Image width in pixels", - "example": 1200 + "total": { + "type": "integer", + "format": "int64", + "description": "Total number of items across all pages", + "example": 150, + "minimum": 0 + }, + "totalPages": { + "type": "integer", + "format": "int64", + "description": "Total number of pages", + "example": 3, + "minimum": 0 } } }, - "PaginatedResponse": { + "PaginatedResponse_LibraryDto": { "type": "object", "description": "Generic paginated response wrapper with HATEOAS links", "required": [ @@ -17517,7 +20286,150 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/BookDto" + "type": "object", + "description": "Library data transfer object", + "required": [ + "id", + "name", + "path", + "isActive", + "seriesStrategy", + "bookStrategy", + "numberStrategy", + "createdAt", + "updatedAt", + "defaultReadingDirection" + ], + "properties": { + "allowedFormats": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Allowed file formats (e.g., [\"CBZ\", \"CBR\", \"EPUB\"])", + "example": [ + "CBZ", + "CBR", + "PDF" + ] + }, + "bookConfig": { + "description": "Book strategy-specific configuration (JSON)" + }, + "bookCount": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Total number of books in this library", + "example": 1250 + }, + "bookStrategy": { + "$ref": "#/components/schemas/BookStrategy", + "description": "Book naming strategy (filename, metadata_first, smart, series_name)" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the library was created", + "example": "2024-01-01T00:00:00Z" + }, + "defaultReadingDirection": { + "type": "string", + "description": "Default reading direction for books in this library (ltr, rtl, ttb or webtoon)", + "example": "ltr" + }, + "description": { + "type": [ + "string", + "null" + ], + "description": "Optional description", + "example": "My comic book collection" + }, + "excludedPatterns": { + "type": [ + "string", + "null" + ], + "description": "Excluded path patterns (newline-separated, e.g., \".DS_Store\\nThumbs.db\")", + "example": ".DS_Store\nThumbs.db" + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Library unique identifier", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "isActive": { + "type": "boolean", + "description": "Whether the library is active", + "example": true + }, + "lastScannedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the library was last scanned", + "example": "2024-01-15T10:30:00Z" + }, + "name": { + "type": "string", + "description": "Library name", + "example": "Comics" + }, + "numberConfig": { + "description": "Number strategy-specific configuration (JSON)" + }, + "numberStrategy": { + "$ref": "#/components/schemas/NumberStrategy", + "description": "Book number strategy (file_order, metadata, filename, smart)" + }, + "path": { + "type": "string", + "description": "Filesystem path to the library root", + "example": "/media/comics" + }, + "scanningConfig": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ScanningConfigDto", + "description": "Scanning configuration for scheduled scans" + } + ] + }, + "seriesConfig": { + "description": "Strategy-specific configuration (JSON)" + }, + "seriesCount": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Total number of series in this library", + "example": 85 + }, + "seriesStrategy": { + "$ref": "#/components/schemas/SeriesStrategy", + "description": "Series detection strategy (series_volume, series_volume_chapter, flat, etc.)" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the library was last updated", + "example": "2024-01-15T10:30:00Z" + } + } }, "description": "The data items for this page" }, @@ -17555,7 +20467,7 @@ } } }, - "PaginatedResponse_ApiKeyDto": { + "PaginatedResponse_SeriesDto": { "type": "object", "description": "Generic paginated response wrapper with HATEOAS links", "required": [ @@ -17571,77 +20483,122 @@ "type": "array", "items": { "type": "object", - "description": "API key data transfer object", + "description": "Series data transfer object", "required": [ "id", - "userId", - "name", - "keyPrefix", - "permissions", - "isActive", + "libraryId", + "libraryName", + "title", + "bookCount", "createdAt", "updatedAt" ], "properties": { + "bookCount": { + "type": "integer", + "format": "int64", + "description": "Total number of books in this series", + "example": 4 + }, "createdAt": { "type": "string", "format": "date-time", - "description": "When the key was created", + "description": "When the series was created", "example": "2024-01-01T00:00:00Z" }, - "expiresAt": { + "hasCustomCover": { "type": [ - "string", + "boolean", "null" ], - "format": "date-time", - "description": "When the key expires (if set)", - "example": "2025-12-31T23:59:59Z" + "description": "Whether the series has a custom cover uploaded", + "example": false }, "id": { "type": "string", "format": "uuid", - "description": "Unique API key identifier", - "example": "550e8400-e29b-41d4-a716-446655440000" + "description": "Series unique identifier", + "example": "550e8400-e29b-41d4-a716-446655440002" }, - "isActive": { - "type": "boolean", - "description": "Whether the key is currently active", - "example": true + "libraryId": { + "type": "string", + "format": "uuid", + "description": "Library unique identifier", + "example": "550e8400-e29b-41d4-a716-446655440000" }, - "keyPrefix": { + "libraryName": { "type": "string", - "description": "Prefix of the key for identification", - "example": "cdx_a1b2c3" + "description": "Name of the library this series belongs to", + "example": "Comics" }, - "lastUsedAt": { + "path": { "type": [ "string", "null" ], - "format": "date-time", - "description": "When the key was last used", - "example": "2024-01-15T10:30:00Z" + "description": "Filesystem path to the series directory", + "example": "/media/comics/Batman - Year One" }, - "name": { + "publisher": { + "type": [ + "string", + "null" + ], + "description": "Publisher name", + "example": "DC Comics" + }, + "selectedCoverSource": { + "type": [ + "string", + "null" + ], + "description": "Selected cover source (e.g., \"first_book\", \"custom\")", + "example": "first_book" + }, + "summary": { + "type": [ + "string", + "null" + ], + "description": "Summary/description from series_metadata", + "example": "The definitive origin story of Batman, following Bruce Wayne's first year as a vigilante." + }, + "title": { "type": "string", - "description": "Human-readable name for the key", - "example": "Mobile App Key" + "description": "Series title from series_metadata", + "example": "Batman: Year One" }, - "permissions": { - "description": "Permissions granted to this key" + "titleSort": { + "type": [ + "string", + "null" + ], + "description": "Sort title from series_metadata (for ordering)", + "example": "batman year one" + }, + "unreadCount": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Number of unread books in this series (user-specific)", + "example": 2 }, "updatedAt": { "type": "string", "format": "date-time", - "description": "When the key was last updated", + "description": "When the series was last updated", "example": "2024-01-15T10:30:00Z" }, - "userId": { - "type": "string", - "format": "uuid", - "description": "Owner user ID", - "example": "550e8400-e29b-41d4-a716-446655440001" + "year": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Release year", + "example": 1987 } } }, @@ -17681,7 +20638,7 @@ } } }, - "PaginatedResponse_BookDto": { + "PaginatedResponse_SharingTagDto": { "type": "object", "description": "Generic paginated response wrapper with HATEOAS links", "required": [ @@ -17697,144 +20654,60 @@ "type": "array", "items": { "type": "object", - "description": "Book data transfer object", + "description": "Sharing tag data transfer object", "required": [ "id", - "libraryId", - "libraryName", - "seriesId", - "seriesName", - "title", - "filePath", - "fileFormat", - "fileSize", - "fileHash", - "pageCount", + "name", + "seriesCount", + "userCount", "createdAt", - "updatedAt", - "deleted" + "updatedAt" ], "properties": { - "analysisError": { - "type": [ - "string", - "null" - ], - "description": "Error message if book analysis failed", - "example": "Failed to parse CBZ: invalid archive" - }, "createdAt": { "type": "string", "format": "date-time", - "description": "When the book was added to the library", + "description": "Creation timestamp", "example": "2024-01-01T00:00:00Z" }, - "deleted": { - "type": "boolean", - "description": "Whether the book has been soft-deleted", - "example": false - }, - "fileFormat": { - "type": "string", - "description": "File format (cbz, cbr, epub, pdf)", - "example": "cbz" - }, - "fileHash": { - "type": "string", - "description": "File hash for deduplication", - "example": "a1b2c3d4e5f6g7h8i9j0" - }, - "filePath": { - "type": "string", - "description": "Filesystem path to the book file", - "example": "/media/comics/Batman/Batman - Year One 001.cbz" - }, - "fileSize": { - "type": "integer", - "format": "int64", - "description": "File size in bytes", - "example": 52428800 - }, - "id": { - "type": "string", - "format": "uuid", - "description": "Book unique identifier", - "example": "550e8400-e29b-41d4-a716-446655440001" - }, - "libraryId": { - "type": "string", - "format": "uuid", - "description": "Library this book belongs to", - "example": "550e8400-e29b-41d4-a716-446655440000" - }, - "libraryName": { - "type": "string", - "description": "Name of the library", - "example": "Comics" - }, - "number": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Book number within the series", - "example": 1 - }, - "pageCount": { - "type": "integer", - "format": "int32", - "description": "Number of pages in the book", - "example": 32 - }, - "readProgress": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ReadProgressResponse", - "description": "User's read progress for this book" - } - ] - }, - "readingDirection": { + "description": { "type": [ "string", "null" ], - "description": "Effective reading direction (from series metadata, or library default if not set)\nValues: ltr, rtl, ttb or webtoon", - "example": "ltr" + "description": "Optional description", + "example": "Content appropriate for children" }, - "seriesId": { + "id": { "type": "string", "format": "uuid", - "description": "Series this book belongs to", - "example": "550e8400-e29b-41d4-a716-446655440002" - }, - "seriesName": { - "type": "string", - "description": "Name of the series", - "example": "Batman: Year One" + "description": "Unique sharing tag identifier", + "example": "550e8400-e29b-41d4-a716-446655440000" }, - "title": { + "name": { "type": "string", - "description": "Book title", - "example": "Batman: Year One #1" + "description": "Display name of the sharing tag", + "example": "Kids Content" }, - "titleSort": { - "type": [ - "string", - "null" - ], - "description": "Title used for sorting (title_sort field)", - "example": "batman year one 001" + "seriesCount": { + "type": "integer", + "format": "int64", + "description": "Number of series tagged with this sharing tag", + "example": 42, + "minimum": 0 }, "updatedAt": { "type": "string", "format": "date-time", - "description": "When the book was last updated", + "description": "Last update timestamp", "example": "2024-01-15T10:30:00Z" + }, + "userCount": { + "type": "integer", + "format": "int64", + "description": "Number of users with grants for this sharing tag", + "example": 5, + "minimum": 0 } } }, @@ -17874,7 +20747,7 @@ } } }, - "PaginatedResponse_GenreDto": { + "PaginatedResponse_TagDto": { "type": "object", "description": "Generic paginated response wrapper with HATEOAS links", "required": [ @@ -17890,7 +20763,7 @@ "type": "array", "items": { "type": "object", - "description": "Genre data transfer object", + "description": "Tag data transfer object", "required": [ "id", "name", @@ -17900,19 +20773,19 @@ "createdAt": { "type": "string", "format": "date-time", - "description": "When the genre was created", + "description": "When the tag was created", "example": "2024-01-01T00:00:00Z" }, "id": { "type": "string", "format": "uuid", - "description": "Genre ID", - "example": "550e8400-e29b-41d4-a716-446655440010" + "description": "Tag ID", + "example": "550e8400-e29b-41d4-a716-446655440020" }, "name": { "type": "string", - "description": "Genre name", - "example": "Action" + "description": "Tag name", + "example": "Completed" }, "seriesCount": { "type": [ @@ -17920,8 +20793,8 @@ "null" ], "format": "int64", - "description": "Number of series with this genre", - "example": 42, + "description": "Number of series with this tag", + "example": 15, "minimum": 0 } } @@ -17962,7 +20835,7 @@ } } }, - "PaginatedResponse_LibraryDto": { + "PaginatedResponse_UserDto": { "type": "object", "description": "Generic paginated response wrapper with HATEOAS links", "required": [ @@ -17978,989 +20851,1482 @@ "type": "array", "items": { "type": "object", - "description": "Library data transfer object", + "description": "User data transfer object", "required": [ "id", - "name", - "path", + "username", + "email", + "role", + "permissions", "isActive", - "seriesStrategy", - "bookStrategy", - "numberStrategy", "createdAt", - "updatedAt", - "defaultReadingDirection" + "updatedAt" ], "properties": { - "allowedFormats": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - }, - "description": "Allowed file formats (e.g., [\"CBZ\", \"CBR\", \"EPUB\"])", - "example": [ - "CBZ", - "CBR", - "PDF" - ] - }, - "bookConfig": { - "description": "Book strategy-specific configuration (JSON)" - }, - "bookCount": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "description": "Total number of books in this library", - "example": 1250 - }, - "bookStrategy": { - "$ref": "#/components/schemas/BookStrategy", - "description": "Book naming strategy (filename, metadata_first, smart, series_name)" - }, "createdAt": { "type": "string", - "format": "date-time", - "description": "When the library was created", - "example": "2024-01-01T00:00:00Z" - }, - "defaultReadingDirection": { - "type": "string", - "description": "Default reading direction for books in this library (ltr, rtl, ttb or webtoon)", - "example": "ltr" - }, - "description": { - "type": [ - "string", - "null" - ], - "description": "Optional description", - "example": "My comic book collection" - }, - "excludedPatterns": { - "type": [ - "string", - "null" - ], - "description": "Excluded path patterns (newline-separated, e.g., \".DS_Store\\nThumbs.db\")", - "example": ".DS_Store\nThumbs.db" + "format": "date-time", + "description": "Account creation timestamp", + "example": "2024-01-01T00:00:00Z" + }, + "email": { + "type": "string", + "description": "User email address", + "example": "john.doe@example.com" }, "id": { "type": "string", "format": "uuid", - "description": "Library unique identifier", + "description": "Unique user identifier", "example": "550e8400-e29b-41d4-a716-446655440000" }, "isActive": { "type": "boolean", - "description": "Whether the library is active", + "description": "Whether the account is active", "example": true }, - "lastScannedAt": { + "lastLoginAt": { "type": [ "string", "null" ], "format": "date-time", - "description": "When the library was last scanned", + "description": "Timestamp of last login", "example": "2024-01-15T10:30:00Z" }, - "name": { - "type": "string", - "description": "Library name", - "example": "Comics" - }, - "numberConfig": { - "description": "Number strategy-specific configuration (JSON)" - }, - "numberStrategy": { - "$ref": "#/components/schemas/NumberStrategy", - "description": "Book number strategy (file_order, metadata, filename, smart)" - }, - "path": { - "type": "string", - "description": "Filesystem path to the library root", - "example": "/media/comics" - }, - "scanningConfig": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ScanningConfigDto", - "description": "Scanning configuration for scheduled scans" - } - ] - }, - "seriesConfig": { - "description": "Strategy-specific configuration (JSON)" - }, - "seriesCount": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "description": "Total number of series in this library", - "example": 85 + "permissions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Custom permissions that extend the role's base permissions" }, - "seriesStrategy": { - "$ref": "#/components/schemas/SeriesStrategy", - "description": "Series detection strategy (series_volume, series_volume_chapter, flat, etc.)" + "role": { + "$ref": "#/components/schemas/UserRole", + "description": "User role (reader, maintainer, admin)" }, "updatedAt": { "type": "string", "format": "date-time", - "description": "When the library was last updated", + "description": "Last account update timestamp", "example": "2024-01-15T10:30:00Z" + }, + "username": { + "type": "string", + "description": "Username for login", + "example": "johndoe" } } }, "description": "The data items for this page" }, - "links": { - "$ref": "#/components/schemas/PaginationLinks", - "description": "HATEOAS navigation links" + "links": { + "$ref": "#/components/schemas/PaginationLinks", + "description": "HATEOAS navigation links" + }, + "page": { + "type": "integer", + "format": "int64", + "description": "Current page number (1-indexed)", + "example": 1, + "minimum": 0 + }, + "pageSize": { + "type": "integer", + "format": "int64", + "description": "Number of items per page", + "example": 50, + "minimum": 0 + }, + "total": { + "type": "integer", + "format": "int64", + "description": "Total number of items across all pages", + "example": 150, + "minimum": 0 + }, + "totalPages": { + "type": "integer", + "format": "int64", + "description": "Total number of pages", + "example": 3, + "minimum": 0 + } + } + }, + "PaginationLinks": { + "type": "object", + "description": "HATEOAS navigation links for paginated responses (RFC 8288)", + "required": [ + "self", + "first", + "last" + ], + "properties": { + "first": { + "type": "string", + "description": "Link to the first page" + }, + "last": { + "type": "string", + "description": "Link to the last page" + }, + "next": { + "type": [ + "string", + "null" + ], + "description": "Link to the next page (null if on last page)" + }, + "prev": { + "type": [ + "string", + "null" + ], + "description": "Link to the previous page (null if on first page)" + }, + "self": { + "type": "string", + "description": "Link to the current page" + } + } + }, + "PatchBookMetadataRequest": { + "type": "object", + "description": "PATCH request for partial update of book metadata\n\nOnly provided fields will be updated. Absent fields are unchanged.\nExplicitly null fields will be cleared.", + "properties": { + "blackAndWhite": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the book is black and white", + "example": false + }, + "colorist": { + "type": [ + "string", + "null" + ], + "description": "Colorist(s) - comma-separated if multiple", + "example": "Richmond Lewis" + }, + "count": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Total count in series", + "example": 4 + }, + "coverArtist": { + "type": [ + "string", + "null" + ], + "description": "Cover artist(s) - comma-separated if multiple", + "example": "David Mazzucchelli" + }, + "day": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Publication day (1-31)", + "example": 1 + }, + "editor": { + "type": [ + "string", + "null" + ], + "description": "Editor(s) - comma-separated if multiple", + "example": "Dennis O'Neil" + }, + "formatDetail": { + "type": [ + "string", + "null" + ], + "description": "Format details", + "example": "Trade Paperback" + }, + "genre": { + "type": [ + "string", + "null" + ], + "description": "Genre", + "example": "Superhero" + }, + "imprint": { + "type": [ + "string", + "null" + ], + "description": "Imprint name", + "example": "DC Black Label" + }, + "inker": { + "type": [ + "string", + "null" + ], + "description": "Inker(s) - comma-separated if multiple", + "example": "David Mazzucchelli" + }, + "isbns": { + "type": [ + "string", + "null" + ], + "description": "ISBN(s) - comma-separated if multiple", + "example": "978-1401207526" + }, + "languageIso": { + "type": [ + "string", + "null" + ], + "description": "ISO language code", + "example": "en" + }, + "letterer": { + "type": [ + "string", + "null" + ], + "description": "Letterer(s) - comma-separated if multiple", + "example": "Todd Klein" + }, + "manga": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the book is manga format", + "example": false + }, + "month": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Publication month (1-12)", + "example": 2 }, - "page": { - "type": "integer", - "format": "int64", - "description": "Current page number (1-indexed)", - "example": 1, - "minimum": 0 + "penciller": { + "type": [ + "string", + "null" + ], + "description": "Penciller(s) - comma-separated if multiple", + "example": "David Mazzucchelli" }, - "pageSize": { - "type": "integer", - "format": "int64", - "description": "Number of items per page", - "example": 50, - "minimum": 0 + "publisher": { + "type": [ + "string", + "null" + ], + "description": "Publisher name", + "example": "DC Comics" }, - "total": { - "type": "integer", - "format": "int64", - "description": "Total number of items across all pages", - "example": 150, - "minimum": 0 + "summary": { + "type": [ + "string", + "null" + ], + "description": "Book summary/description", + "example": "Bruce Wayne returns to Gotham City." }, - "totalPages": { - "type": "integer", - "format": "int64", - "description": "Total number of pages", - "example": 3, - "minimum": 0 + "volume": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Volume number", + "example": 1 + }, + "web": { + "type": [ + "string", + "null" + ], + "description": "Web URL for more information", + "example": "https://dc.com/batman-year-one" + }, + "writer": { + "type": [ + "string", + "null" + ], + "description": "Writer(s) - comma-separated if multiple", + "example": "Frank Miller" + }, + "year": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Publication year", + "example": 1987 } } }, - "PaginatedResponse_SeriesDto": { + "PatchBookRequest": { "type": "object", - "description": "Generic paginated response wrapper with HATEOAS links", - "required": [ - "data", - "page", - "pageSize", - "total", - "totalPages", - "links" - ], + "description": "PATCH request for updating book core fields (title, number)\n\nOnly provided fields will be updated. Absent fields are unchanged.\nExplicitly null fields will be cleared.", "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "description": "Series data transfer object", - "required": [ - "id", - "libraryId", - "libraryName", - "title", - "bookCount", - "createdAt", - "updatedAt" - ], - "properties": { - "bookCount": { - "type": "integer", - "format": "int64", - "description": "Total number of books in this series", - "example": 4 - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the series was created", - "example": "2024-01-01T00:00:00Z" - }, - "hasCustomCover": { - "type": [ - "boolean", - "null" - ], - "description": "Whether the series has a custom cover uploaded", - "example": false - }, - "id": { - "type": "string", - "format": "uuid", - "description": "Series unique identifier", - "example": "550e8400-e29b-41d4-a716-446655440002" - }, - "libraryId": { - "type": "string", - "format": "uuid", - "description": "Library unique identifier", - "example": "550e8400-e29b-41d4-a716-446655440000" - }, - "libraryName": { - "type": "string", - "description": "Name of the library this series belongs to", - "example": "Comics" - }, - "path": { - "type": [ - "string", - "null" - ], - "description": "Filesystem path to the series directory", - "example": "/media/comics/Batman - Year One" - }, - "publisher": { - "type": [ - "string", - "null" - ], - "description": "Publisher name", - "example": "DC Comics" - }, - "selectedCoverSource": { - "type": [ - "string", - "null" - ], - "description": "Selected cover source (e.g., \"first_book\", \"custom\")", - "example": "first_book" - }, - "summary": { - "type": [ - "string", - "null" - ], - "description": "Summary/description from series_metadata", - "example": "The definitive origin story of Batman, following Bruce Wayne's first year as a vigilante." - }, - "title": { - "type": "string", - "description": "Series title from series_metadata", - "example": "Batman: Year One" - }, - "titleSort": { - "type": [ - "string", - "null" - ], - "description": "Sort title from series_metadata (for ordering)", - "example": "batman year one" - }, - "unreadCount": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "description": "Number of unread books in this series (user-specific)", - "example": 2 - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the series was last updated", - "example": "2024-01-15T10:30:00Z" - }, - "year": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Release year", - "example": 1987 - } - } - }, - "description": "The data items for this page" + "number": { + "type": [ + "number", + "null" + ], + "format": "double", + "description": "Book number (for sorting within series). Supports decimals like 1.5 for special chapters.", + "example": 1.5 + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Book title (display name)", + "example": "Chapter 1: The Beginning" + } + } + }, + "PatchSeriesMetadataRequest": { + "type": "object", + "description": "PATCH request for partial update of series metadata\n\nOnly provided fields will be updated. Absent fields are unchanged.\nExplicitly null fields will be cleared.", + "properties": { + "ageRating": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Age rating (e.g., 13, 16, 18)", + "example": 16 }, - "links": { - "$ref": "#/components/schemas/PaginationLinks", - "description": "HATEOAS navigation links" + "customMetadata": { + "type": [ + "object", + "null" + ], + "description": "Custom JSON metadata for extensions" }, - "page": { - "type": "integer", - "format": "int64", - "description": "Current page number (1-indexed)", - "example": 1, - "minimum": 0 + "imprint": { + "type": [ + "string", + "null" + ], + "description": "Imprint (sub-publisher)", + "example": "Vertigo" }, - "pageSize": { - "type": "integer", - "format": "int64", - "description": "Number of items per page", - "example": 50, - "minimum": 0 + "language": { + "type": [ + "string", + "null" + ], + "description": "Language (BCP47 format: \"en\", \"ja\", \"ko\")", + "example": "en" }, - "total": { + "publisher": { + "type": [ + "string", + "null" + ], + "description": "Publisher name", + "example": "DC Comics" + }, + "readingDirection": { + "type": [ + "string", + "null" + ], + "description": "Reading direction (ltr, rtl, ttb or webtoon)", + "example": "ltr" + }, + "status": { + "type": [ + "string", + "null" + ], + "description": "Series status (ongoing, ended, hiatus, abandoned, unknown)", + "example": "ended" + }, + "summary": { + "type": [ + "string", + "null" + ], + "description": "Series description/summary", + "example": "The definitive origin story of Batman." + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Series title/name", + "example": "Batman: Year One" + }, + "titleSort": { + "type": [ + "string", + "null" + ], + "description": "Custom sort name for ordering", + "example": "Batman Year One" + }, + "totalBookCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Expected total book count (for ongoing series)", + "example": 4 + }, + "year": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Release year", + "example": 1987 + } + } + }, + "PatchSeriesRequest": { + "type": "object", + "description": "PATCH request for updating series title\n\nOnly provided fields will be updated. Absent fields are unchanged.\nExplicitly null fields will be cleared (where applicable).", + "properties": { + "title": { + "type": [ + "string", + "null" + ], + "description": "Series title (stored in series_metadata.title)", + "example": "Batman: Year One" + } + } + }, + "PdfCacheCleanupResultDto": { + "type": "object", + "description": "Result of a PDF cache cleanup operation", + "required": [ + "files_deleted", + "bytes_reclaimed", + "bytes_reclaimed_human" + ], + "properties": { + "bytes_reclaimed": { "type": "integer", "format": "int64", - "description": "Total number of items across all pages", - "example": 150, + "description": "Bytes freed by the cleanup", + "example": 26214400, "minimum": 0 }, - "totalPages": { + "bytes_reclaimed_human": { + "type": "string", + "description": "Human-readable size reclaimed (e.g., \"25.0 MB\")", + "example": "25.0 MB" + }, + "files_deleted": { "type": "integer", "format": "int64", - "description": "Total number of pages", - "example": 3, + "description": "Number of cached page files deleted", + "example": 250, "minimum": 0 } } }, - "PaginatedResponse_SharingTagDto": { + "PdfCacheStatsDto": { "type": "object", - "description": "Generic paginated response wrapper with HATEOAS links", + "description": "Statistics about the PDF page cache", "required": [ - "data", - "page", - "pageSize", - "total", - "totalPages", - "links" + "total_files", + "total_size_bytes", + "total_size_human", + "book_count", + "cache_dir", + "cache_enabled" ], "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "description": "Sharing tag data transfer object", - "required": [ - "id", - "name", - "seriesCount", - "userCount", - "createdAt", - "updatedAt" - ], - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "description": "Creation timestamp", - "example": "2024-01-01T00:00:00Z" - }, - "description": { - "type": [ - "string", - "null" - ], - "description": "Optional description", - "example": "Content appropriate for children" - }, - "id": { - "type": "string", - "format": "uuid", - "description": "Unique sharing tag identifier", - "example": "550e8400-e29b-41d4-a716-446655440000" - }, - "name": { - "type": "string", - "description": "Display name of the sharing tag", - "example": "Kids Content" - }, - "seriesCount": { - "type": "integer", - "format": "int64", - "description": "Number of series tagged with this sharing tag", - "example": 42, - "minimum": 0 - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "Last update timestamp", - "example": "2024-01-15T10:30:00Z" - }, - "userCount": { - "type": "integer", - "format": "int64", - "description": "Number of users with grants for this sharing tag", - "example": 5, - "minimum": 0 - } - } - }, - "description": "The data items for this page" - }, - "links": { - "$ref": "#/components/schemas/PaginationLinks", - "description": "HATEOAS navigation links" - }, - "page": { + "book_count": { "type": "integer", "format": "int64", - "description": "Current page number (1-indexed)", - "example": 1, + "description": "Number of unique books with cached pages", + "example": 45, "minimum": 0 }, - "pageSize": { - "type": "integer", - "format": "int64", - "description": "Number of items per page", - "example": 50, + "cache_dir": { + "type": "string", + "description": "Path to the cache directory", + "example": "/data/cache" + }, + "cache_enabled": { + "type": "boolean", + "description": "Whether the PDF page cache is enabled", + "example": true + }, + "oldest_file_age_days": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Age of the oldest cached file in days (if any files exist)", + "example": 15, "minimum": 0 }, - "total": { + "total_files": { "type": "integer", "format": "int64", - "description": "Total number of items across all pages", - "example": 150, + "description": "Total number of cached page files", + "example": 1500, "minimum": 0 }, - "totalPages": { + "total_size_bytes": { "type": "integer", "format": "int64", - "description": "Total number of pages", - "example": 3, + "description": "Total size of cache in bytes", + "example": 157286400, "minimum": 0 + }, + "total_size_human": { + "type": "string", + "description": "Human-readable total size (e.g., \"150.0 MB\")", + "example": "150.0 MB" } } }, - "PaginatedResponse_TagDto": { + "PluginActionDto": { "type": "object", - "description": "Generic paginated response wrapper with HATEOAS links", + "description": "A plugin action available for a specific scope", "required": [ - "data", - "page", - "pageSize", - "total", - "totalPages", - "links" + "pluginId", + "pluginName", + "pluginDisplayName", + "actionType", + "label" ], "properties": { - "data": { + "actionType": { + "type": "string", + "description": "Action type (e.g., \"metadata_search\", \"metadata_get\")" + }, + "description": { + "type": [ + "string", + "null" + ], + "description": "Description of the action" + }, + "icon": { + "type": [ + "string", + "null" + ], + "description": "Icon hint for UI (optional)" + }, + "label": { + "type": "string", + "description": "Human-readable label for the action" + }, + "libraryIds": { "type": "array", "items": { - "type": "object", - "description": "Tag data transfer object", - "required": [ - "id", - "name", - "createdAt" - ], - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the tag was created", - "example": "2024-01-01T00:00:00Z" - }, - "id": { - "type": "string", - "format": "uuid", - "description": "Tag ID", - "example": "550e8400-e29b-41d4-a716-446655440020" - }, - "name": { - "type": "string", - "description": "Tag name", - "example": "Completed" - }, - "seriesCount": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "description": "Number of series with this tag", - "example": 15, - "minimum": 0 - } - } + "type": "string", + "format": "uuid" }, - "description": "The data items for this page" - }, - "links": { - "$ref": "#/components/schemas/PaginationLinks", - "description": "HATEOAS navigation links" - }, - "page": { - "type": "integer", - "format": "int64", - "description": "Current page number (1-indexed)", - "example": 1, - "minimum": 0 + "description": "Library IDs this plugin applies to (empty means all libraries)\nUsed by frontend to filter which plugins show up for each library" }, - "pageSize": { - "type": "integer", - "format": "int64", - "description": "Number of items per page", - "example": 50, - "minimum": 0 + "pluginDisplayName": { + "type": "string", + "description": "Plugin display name" }, - "total": { - "type": "integer", - "format": "int64", - "description": "Total number of items across all pages", - "example": 150, - "minimum": 0 + "pluginId": { + "type": "string", + "format": "uuid", + "description": "Plugin ID" }, - "totalPages": { - "type": "integer", - "format": "int64", - "description": "Total number of pages", - "example": 3, - "minimum": 0 + "pluginName": { + "type": "string", + "description": "Plugin name" } } }, - "PaginatedResponse_UserDto": { + "PluginActionRequest": { + "oneOf": [ + { + "type": "object", + "description": "Metadata plugin actions (search, get, match)", + "required": [ + "metadata" + ], + "properties": { + "metadata": { + "type": "object", + "description": "Metadata plugin actions (search, get, match)", + "required": [ + "action", + "content_type" + ], + "properties": { + "action": { + "$ref": "#/components/schemas/MetadataAction", + "description": "The metadata action to perform" + }, + "content_type": { + "$ref": "#/components/schemas/MetadataContentType", + "description": "Content type (series or book)" + }, + "params": { + "description": "Action-specific parameters" + } + } + } + } + }, + { + "type": "string", + "description": "Health check (works for any plugin type)", + "enum": [ + "ping" + ] + } + ], + "description": "Plugin action request - tagged by plugin type\n\nEach plugin type has its own set of valid actions.\nThis ensures type safety - you can't call a metadata action on a sync plugin." + }, + "PluginActionsResponse": { "type": "object", - "description": "Generic paginated response wrapper with HATEOAS links", + "description": "Response containing available plugin actions for a scope", "required": [ - "data", - "page", - "pageSize", - "total", - "totalPages", - "links" + "actions", + "scope" ], "properties": { - "data": { + "actions": { "type": "array", "items": { - "type": "object", - "description": "User data transfer object", - "required": [ - "id", - "username", - "email", - "role", - "permissions", - "isActive", - "createdAt", - "updatedAt" - ], - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "description": "Account creation timestamp", - "example": "2024-01-01T00:00:00Z" - }, - "email": { - "type": "string", - "description": "User email address", - "example": "john.doe@example.com" - }, - "id": { - "type": "string", - "format": "uuid", - "description": "Unique user identifier", - "example": "550e8400-e29b-41d4-a716-446655440000" - }, - "isActive": { - "type": "boolean", - "description": "Whether the account is active", - "example": true - }, - "lastLoginAt": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "Timestamp of last login", - "example": "2024-01-15T10:30:00Z" - }, - "permissions": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Custom permissions that extend the role's base permissions" - }, - "role": { - "$ref": "#/components/schemas/UserRole", - "description": "User role (reader, maintainer, admin)" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "Last account update timestamp", - "example": "2024-01-15T10:30:00Z" - }, - "username": { - "type": "string", - "description": "Username for login", - "example": "johndoe" - } - } + "$ref": "#/components/schemas/PluginActionDto" }, - "description": "The data items for this page" - }, - "links": { - "$ref": "#/components/schemas/PaginationLinks", - "description": "HATEOAS navigation links" - }, - "page": { - "type": "integer", - "format": "int64", - "description": "Current page number (1-indexed)", - "example": 1, - "minimum": 0 - }, - "pageSize": { - "type": "integer", - "format": "int64", - "description": "Number of items per page", - "example": 50, - "minimum": 0 + "description": "Available actions grouped by plugin" }, - "total": { - "type": "integer", - "format": "int64", - "description": "Total number of items across all pages", - "example": 150, - "minimum": 0 + "scope": { + "type": "string", + "description": "The scope these actions are for" + } + } + }, + "PluginCapabilitiesDto": { + "type": "object", + "description": "Plugin capabilities", + "properties": { + "metadataProvider": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Content types this plugin can provide metadata for (e.g., [\"series\", \"book\"])" }, - "totalPages": { - "type": "integer", - "format": "int64", - "description": "Total number of pages", - "example": 3, - "minimum": 0 + "userSyncProvider": { + "type": "boolean", + "description": "Can sync user reading progress" } } }, - "PaginationLinks": { + "PluginDto": { "type": "object", - "description": "HATEOAS navigation links for paginated responses (RFC 8288)", + "description": "A plugin (credentials are never exposed)", "required": [ - "self", - "first", - "last" + "id", + "name", + "displayName", + "pluginType", + "command", + "args", + "env", + "permissions", + "scopes", + "libraryIds", + "hasCredentials", + "credentialDelivery", + "config", + "enabled", + "healthStatus", + "failureCount", + "createdAt", + "updatedAt" ], "properties": { - "first": { + "args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Command arguments", + "example": [ + "/opt/codex/plugins/mangabaka/dist/index.js" + ] + }, + "command": { "type": "string", - "description": "Link to the first page" + "description": "Command to spawn the plugin", + "example": "node" }, - "last": { + "config": { + "description": "Plugin-specific configuration" + }, + "createdAt": { "type": "string", - "description": "Link to the last page" + "format": "date-time", + "description": "When the plugin was created" }, - "next": { + "credentialDelivery": { + "type": "string", + "description": "How credentials are delivered to the plugin", + "example": "env" + }, + "description": { "type": [ "string", "null" ], - "description": "Link to the next page (null if on last page)" + "description": "Description of the plugin", + "example": "Fetch manga metadata from MangaBaka (MangaUpdates)" }, - "prev": { + "disabledReason": { "type": [ "string", "null" ], - "description": "Link to the previous page (null if on first page)" + "description": "Reason the plugin was disabled" }, - "self": { + "displayName": { "type": "string", - "description": "Link to the current page" - } - } - }, - "PatchBookMetadataRequest": { - "type": "object", - "description": "PATCH request for partial update of book metadata\n\nOnly provided fields will be updated. Absent fields are unchanged.\nExplicitly null fields will be cleared.", - "properties": { - "blackAndWhite": { - "type": [ - "boolean", - "null" - ], - "description": "Whether the book is black and white", - "example": false + "description": "Human-readable display name", + "example": "MangaBaka" }, - "colorist": { - "type": [ - "string", - "null" - ], - "description": "Colorist(s) - comma-separated if multiple", - "example": "Richmond Lewis" + "enabled": { + "type": "boolean", + "description": "Whether the plugin is enabled", + "example": true }, - "count": { - "type": [ - "integer", - "null" - ], + "env": { + "description": "Additional environment variables (non-sensitive only)" + }, + "failureCount": { + "type": "integer", "format": "int32", - "description": "Total count in series", - "example": 4 + "description": "Number of consecutive failures", + "example": 0 }, - "coverArtist": { - "type": [ - "string", - "null" - ], - "description": "Cover artist(s) - comma-separated if multiple", - "example": "David Mazzucchelli" + "hasCredentials": { + "type": "boolean", + "description": "Whether credentials have been set (actual credentials are never returned)", + "example": true }, - "day": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Publication day (1-31)", - "example": 1 + "healthStatus": { + "type": "string", + "description": "Health status: unknown, healthy, degraded, unhealthy, disabled", + "example": "healthy" }, - "editor": { + "id": { + "type": "string", + "format": "uuid", + "description": "Plugin ID", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "lastFailureAt": { "type": [ "string", "null" ], - "description": "Editor(s) - comma-separated if multiple", - "example": "Dennis O'Neil" + "format": "date-time", + "description": "When the last failure occurred" }, - "formatDetail": { + "lastSuccessAt": { "type": [ "string", "null" ], - "description": "Format details", - "example": "Trade Paperback" + "format": "date-time", + "description": "When the last successful operation occurred" }, - "genre": { + "libraryIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Library IDs this plugin applies to (empty = all libraries)", + "example": [] + }, + "manifest": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PluginManifestDto", + "description": "Cached manifest from plugin (if available)" + } + ] + }, + "name": { + "type": "string", + "description": "Unique identifier (e.g., \"mangabaka\")", + "example": "mangabaka" + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "RBAC permissions for metadata writes", + "example": [ + "metadata:write:summary", + "metadata:write:genres" + ] + }, + "pluginType": { + "type": "string", + "description": "Plugin type: \"system\" (admin-configured) or \"user\" (per-user instances)", + "example": "system" + }, + "rateLimitRequestsPerMinute": { "type": [ - "string", + "integer", "null" ], - "description": "Genre", - "example": "Superhero" + "format": "int32", + "description": "Rate limit in requests per minute (None = no limit)", + "example": 60 }, - "imprint": { + "scopes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Scopes where plugin can be invoked", + "example": [ + "series:detail", + "series:bulk" + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the plugin was last updated" + }, + "workingDirectory": { "type": [ "string", "null" ], - "description": "Imprint name", - "example": "DC Black Label" + "description": "Working directory for the plugin process" + } + } + }, + "PluginFailureDto": { + "type": "object", + "description": "A single plugin failure event", + "required": [ + "id", + "errorMessage", + "occurredAt" + ], + "properties": { + "context": { + "description": "Additional context (parameters, stack trace, etc.)" }, - "inker": { + "errorCode": { "type": [ "string", "null" ], - "description": "Inker(s) - comma-separated if multiple", - "example": "David Mazzucchelli" + "description": "Error code for categorization", + "example": "TIMEOUT" }, - "isbns": { + "errorMessage": { + "type": "string", + "description": "Human-readable error message", + "example": "Connection timeout after 30s" + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Failure ID" + }, + "method": { "type": [ "string", "null" ], - "description": "ISBN(s) - comma-separated if multiple", - "example": "978-1401207526" + "description": "Which method failed", + "example": "metadata/search" }, - "languageIso": { + "occurredAt": { + "type": "string", + "format": "date-time", + "description": "When the failure occurred" + }, + "requestSummary": { "type": [ "string", "null" ], - "description": "ISO language code", - "example": "en" + "description": "Sanitized summary of request parameters (sensitive fields redacted)", + "example": "query: \"One Piece\", limit: 10" + } + } + }, + "PluginFailuresResponse": { + "type": "object", + "description": "Response containing plugin failure history", + "required": [ + "failures", + "total", + "windowFailures", + "windowSeconds", + "threshold" + ], + "properties": { + "failures": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PluginFailureDto" + }, + "description": "List of failure events" }, - "letterer": { + "threshold": { + "type": "integer", + "format": "int32", + "description": "Threshold for auto-disable", + "example": 3, + "minimum": 0 + }, + "total": { + "type": "integer", + "format": "int64", + "description": "Total number of failures (for pagination)", + "minimum": 0 + }, + "windowFailures": { + "type": "integer", + "format": "int64", + "description": "Number of failures within the current time window", + "minimum": 0 + }, + "windowSeconds": { + "type": "integer", + "format": "int64", + "description": "Time window size in seconds", + "example": 3600 + } + } + }, + "PluginHealthDto": { + "type": "object", + "description": "Plugin health information", + "required": [ + "pluginId", + "name", + "healthStatus", + "enabled", + "failureCount" + ], + "properties": { + "disabledReason": { "type": [ "string", "null" ], - "description": "Letterer(s) - comma-separated if multiple", - "example": "Todd Klein" + "description": "Reason the plugin was disabled" }, - "manga": { - "type": [ - "boolean", - "null" - ], - "description": "Whether the book is manga format", - "example": false + "enabled": { + "type": "boolean", + "description": "Whether the plugin is enabled" }, - "month": { - "type": [ - "integer", - "null" - ], + "failureCount": { + "type": "integer", "format": "int32", - "description": "Publication month (1-12)", - "example": 2 + "description": "Number of consecutive failures" }, - "penciller": { + "healthStatus": { + "type": "string", + "description": "Current health status" + }, + "lastFailureAt": { "type": [ "string", "null" ], - "description": "Penciller(s) - comma-separated if multiple", - "example": "David Mazzucchelli" + "format": "date-time", + "description": "When the last failure occurred" }, - "publisher": { + "lastSuccessAt": { "type": [ "string", "null" ], - "description": "Publisher name", - "example": "DC Comics" + "format": "date-time", + "description": "When the last successful operation occurred" }, - "summary": { + "name": { + "type": "string", + "description": "Plugin name" + }, + "pluginId": { + "type": "string", + "format": "uuid", + "description": "Plugin ID" + } + } + }, + "PluginHealthResponse": { + "type": "object", + "description": "Response containing plugin health history/summary", + "required": [ + "health" + ], + "properties": { + "health": { + "$ref": "#/components/schemas/PluginHealthDto", + "description": "Plugin health information" + } + } + }, + "PluginManifestDto": { + "type": "object", + "description": "Plugin manifest from the plugin itself", + "required": [ + "name", + "displayName", + "version", + "protocolVersion", + "capabilities", + "contentTypes" + ], + "properties": { + "author": { "type": [ "string", "null" ], - "description": "Book summary/description", - "example": "Bruce Wayne returns to Gotham City." + "description": "Author" }, - "volume": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Volume number", - "example": 1 + "capabilities": { + "$ref": "#/components/schemas/PluginCapabilitiesDto", + "description": "Plugin capabilities" }, - "web": { + "contentTypes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Supported content types" + }, + "description": { "type": [ "string", "null" ], - "description": "Web URL for more information", - "example": "https://dc.com/batman-year-one" + "description": "Description" }, - "writer": { + "displayName": { + "type": "string", + "description": "Display name for UI" + }, + "homepage": { "type": [ "string", "null" ], - "description": "Writer(s) - comma-separated if multiple", - "example": "Frank Miller" + "description": "Homepage URL" }, - "year": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Publication year", - "example": 1987 + "name": { + "type": "string", + "description": "Unique identifier" + }, + "protocolVersion": { + "type": "string", + "description": "Protocol version" + }, + "requiredCredentials": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CredentialFieldDto" + }, + "description": "Required credentials" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Supported scopes" + }, + "version": { + "type": "string", + "description": "Semantic version" } } }, - "PatchSeriesMetadataRequest": { + "PluginMethodMetricsDto": { "type": "object", - "description": "PATCH request for partial update of series metadata\n\nOnly provided fields will be updated. Absent fields are unchanged.\nExplicitly null fields will be cleared.", + "description": "Metrics breakdown by method for a plugin", + "required": [ + "method", + "requests_total", + "requests_success", + "requests_failed", + "avg_duration_ms" + ], "properties": { - "ageRating": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Age rating (e.g., 13, 16, 18)", - "example": 16 + "avg_duration_ms": { + "type": "number", + "format": "double", + "description": "Average duration in milliseconds", + "example": 180.5 }, - "customMetadata": { + "method": { + "type": "string", + "description": "Method name", + "example": "search" + }, + "requests_failed": { + "type": "integer", + "format": "int64", + "description": "Failed requests", + "example": 5, + "minimum": 0 + }, + "requests_success": { + "type": "integer", + "format": "int64", + "description": "Successful requests", + "example": 195, + "minimum": 0 + }, + "requests_total": { + "type": "integer", + "format": "int64", + "description": "Total requests for this method", + "example": 200, + "minimum": 0 + } + } + }, + "PluginMetricsDto": { + "type": "object", + "description": "Metrics for a single plugin", + "required": [ + "plugin_id", + "plugin_name", + "requests_total", + "requests_success", + "requests_failed", + "avg_duration_ms", + "rate_limit_rejections", + "error_rate_pct", + "health_status" + ], + "properties": { + "avg_duration_ms": { + "type": "number", + "format": "double", + "description": "Average request duration in milliseconds", + "example": 250.5 + }, + "by_method": { "type": [ "object", "null" ], - "description": "Custom JSON metadata for extensions" + "description": "Per-method breakdown", + "additionalProperties": { + "$ref": "#/components/schemas/PluginMethodMetricsDto" + }, + "propertyNames": { + "type": "string" + } }, - "imprint": { - "type": [ - "string", - "null" - ], - "description": "Imprint (sub-publisher)", - "example": "Vertigo" + "error_rate_pct": { + "type": "number", + "format": "double", + "description": "Error rate as percentage", + "example": 4.0 }, - "language": { + "failure_counts": { "type": [ - "string", + "object", "null" ], - "description": "Language (BCP47 format: \"en\", \"ja\", \"ko\")", - "example": "en" + "description": "Failure counts by error code", + "additionalProperties": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "propertyNames": { + "type": "string" + } }, - "publisher": { + "health_status": { + "type": "string", + "description": "Current health status", + "example": "healthy" + }, + "last_failure": { "type": [ "string", "null" ], - "description": "Publisher name", - "example": "DC Comics" + "format": "date-time", + "description": "Last failure timestamp" }, - "readingDirection": { + "last_success": { "type": [ "string", "null" ], - "description": "Reading direction (ltr, rtl, ttb or webtoon)", - "example": "ltr" + "format": "date-time", + "description": "Last successful request timestamp" }, - "status": { + "plugin_id": { + "type": "string", + "format": "uuid", + "description": "Plugin ID", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "plugin_name": { + "type": "string", + "description": "Plugin name", + "example": "AniList Provider" + }, + "rate_limit_rejections": { + "type": "integer", + "format": "int64", + "description": "Number of rate limit rejections", + "example": 2, + "minimum": 0 + }, + "requests_failed": { + "type": "integer", + "format": "int64", + "description": "Failed requests", + "example": 20, + "minimum": 0 + }, + "requests_success": { + "type": "integer", + "format": "int64", + "description": "Successful requests", + "example": 480, + "minimum": 0 + }, + "requests_total": { + "type": "integer", + "format": "int64", + "description": "Total requests made", + "example": 500, + "minimum": 0 + } + } + }, + "PluginMetricsResponse": { + "type": "object", + "description": "Plugin metrics response - current performance statistics for all plugins", + "required": [ + "updated_at", + "summary", + "plugins" + ], + "properties": { + "plugins": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PluginMetricsDto" + }, + "description": "Per-plugin breakdown" + }, + "summary": { + "$ref": "#/components/schemas/PluginMetricsSummaryDto", + "description": "Overall summary statistics" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "When the metrics were last updated", + "example": "2026-01-30T12:00:00Z" + } + } + }, + "PluginMetricsSummaryDto": { + "type": "object", + "description": "Summary metrics across all plugins", + "required": [ + "total_plugins", + "healthy_plugins", + "degraded_plugins", + "unhealthy_plugins", + "total_requests", + "total_success", + "total_failed", + "total_rate_limit_rejections" + ], + "properties": { + "degraded_plugins": { + "type": "integer", + "format": "int64", + "description": "Number of degraded plugins", + "example": 1, + "minimum": 0 + }, + "healthy_plugins": { + "type": "integer", + "format": "int64", + "description": "Number of healthy plugins", + "example": 2, + "minimum": 0 + }, + "total_failed": { + "type": "integer", + "format": "int64", + "description": "Total failed requests", + "example": 100, + "minimum": 0 + }, + "total_plugins": { + "type": "integer", + "format": "int64", + "description": "Total number of registered plugins", + "example": 3, + "minimum": 0 + }, + "total_rate_limit_rejections": { + "type": "integer", + "format": "int64", + "description": "Total rate limit rejections", + "example": 5, + "minimum": 0 + }, + "total_requests": { + "type": "integer", + "format": "int64", + "description": "Total requests made across all plugins", + "example": 1500, + "minimum": 0 + }, + "total_success": { + "type": "integer", + "format": "int64", + "description": "Total successful requests", + "example": 1400, + "minimum": 0 + }, + "unhealthy_plugins": { + "type": "integer", + "format": "int64", + "description": "Number of unhealthy plugins", + "example": 0, + "minimum": 0 + } + } + }, + "PluginSearchResponse": { + "type": "object", + "description": "Response containing search results from a plugin", + "required": [ + "results", + "pluginId", + "pluginName" + ], + "properties": { + "nextCursor": { "type": [ "string", "null" ], - "description": "Series status (ongoing, ended, hiatus, abandoned, unknown)", - "example": "ended" + "description": "Cursor for next page (if available)" + }, + "pluginId": { + "type": "string", + "format": "uuid", + "description": "Plugin that provided the results" + }, + "pluginName": { + "type": "string", + "description": "Plugin name" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PluginSearchResultDto" + }, + "description": "Search results" + } + } + }, + "PluginSearchResultDto": { + "type": "object", + "description": "Search result from a plugin", + "required": [ + "externalId", + "title" + ], + "properties": { + "alternateTitles": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Alternative titles" }, - "summary": { + "coverUrl": { "type": [ "string", "null" ], - "description": "Series description/summary", - "example": "The definitive origin story of Batman." + "description": "Cover image URL" }, - "title": { - "type": [ - "string", - "null" - ], - "description": "Series title/name", - "example": "Batman: Year One" + "externalId": { + "type": "string", + "description": "External ID from the provider" }, - "titleSort": { - "type": [ - "string", - "null" - ], - "description": "Custom sort name for ordering", - "example": "Batman Year One" + "preview": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SearchResultPreviewDto", + "description": "Preview data for search results" + } + ] }, - "totalBookCount": { + "relevanceScore": { "type": [ - "integer", + "number", "null" ], - "format": "int32", - "description": "Expected total book count (for ongoing series)", - "example": 4 + "format": "double", + "description": "Relevance score (0.0-1.0). Optional - if not provided, result order indicates relevance." + }, + "title": { + "type": "string", + "description": "Primary title" }, "year": { "type": [ @@ -18968,98 +22334,119 @@ "null" ], "format": "int32", - "description": "Release year", - "example": 1987 + "description": "Year of publication" } } }, - "PdfCacheCleanupResultDto": { + "PluginStatusResponse": { "type": "object", - "description": "Result of a PDF cache cleanup operation", + "description": "Response after enabling or disabling a plugin", "required": [ - "files_deleted", - "bytes_reclaimed", - "bytes_reclaimed_human" + "plugin", + "message" ], "properties": { - "bytes_reclaimed": { - "type": "integer", + "healthCheckError": { + "type": [ + "string", + "null" + ], + "description": "Health check error message (None if passed or not performed)" + }, + "healthCheckLatencyMs": { + "type": [ + "integer", + "null" + ], "format": "int64", - "description": "Bytes freed by the cleanup", - "example": 26214400, + "description": "Health check latency in milliseconds (None if not performed)", + "example": 150, "minimum": 0 }, - "bytes_reclaimed_human": { + "healthCheckPassed": { + "type": [ + "boolean", + "null" + ], + "description": "Health check passed (None if not performed)", + "example": true + }, + "healthCheckPerformed": { + "type": "boolean", + "description": "Whether a health check was performed", + "example": true + }, + "message": { "type": "string", - "description": "Human-readable size reclaimed (e.g., \"25.0 MB\")", - "example": "25.0 MB" + "description": "Status change message", + "example": "Plugin enabled successfully" }, - "files_deleted": { - "type": "integer", - "format": "int64", - "description": "Number of cached page files deleted", - "example": 250, - "minimum": 0 + "plugin": { + "$ref": "#/components/schemas/PluginDto", + "description": "The updated plugin" } } }, - "PdfCacheStatsDto": { + "PluginTestResult": { "type": "object", - "description": "Statistics about the PDF page cache", + "description": "Response from testing a plugin connection", "required": [ - "total_files", - "total_size_bytes", - "total_size_human", - "book_count", - "cache_dir", - "cache_enabled" + "success", + "message" ], "properties": { - "book_count": { - "type": "integer", + "latencyMs": { + "type": [ + "integer", + "null" + ], "format": "int64", - "description": "Number of unique books with cached pages", - "example": 45, + "description": "Response latency in milliseconds", + "example": 150, "minimum": 0 }, - "cache_dir": { + "manifest": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PluginManifestDto", + "description": "Plugin manifest (if connection succeeded)" + } + ] + }, + "message": { "type": "string", - "description": "Path to the cache directory", - "example": "/data/cache" + "description": "Test result message", + "example": "Successfully connected to plugin" }, - "cache_enabled": { + "success": { "type": "boolean", - "description": "Whether the PDF page cache is enabled", + "description": "Whether the test was successful", "example": true + } + } + }, + "PluginsListResponse": { + "type": "object", + "description": "Response containing a list of plugins", + "required": [ + "plugins", + "total" + ], + "properties": { + "plugins": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PluginDto" + }, + "description": "List of plugins" }, - "oldest_file_age_days": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Age of the oldest cached file in days (if any files exist)", - "example": 15, - "minimum": 0 - }, - "total_files": { - "type": "integer", - "format": "int64", - "description": "Total number of cached page files", - "example": 1500, - "minimum": 0 - }, - "total_size_bytes": { + "total": { "type": "integer", - "format": "int64", - "description": "Total size of cache in bytes", - "example": 157286400, + "description": "Total count", "minimum": 0 - }, - "total_size_human": { - "type": "string", - "description": "Human-readable total size (e.g., \"150.0 MB\")", - "example": "150.0 MB" } } }, @@ -19113,6 +22500,44 @@ } } }, + "PreviewSummary": { + "type": "object", + "description": "Summary of preview results", + "required": [ + "willApply", + "locked", + "noPermission", + "unchanged", + "notProvided" + ], + "properties": { + "locked": { + "type": "integer", + "description": "Number of fields that are locked", + "minimum": 0 + }, + "noPermission": { + "type": "integer", + "description": "Number of fields with no permission", + "minimum": 0 + }, + "notProvided": { + "type": "integer", + "description": "Number of fields not provided", + "minimum": 0 + }, + "unchanged": { + "type": "integer", + "description": "Number of fields that are unchanged", + "minimum": 0 + }, + "willApply": { + "type": "integer", + "description": "Number of fields that will be applied", + "minimum": 0 + } + } + }, "PublicSettingDto": { "type": "object", "description": "Public setting DTO (for non-admin users)\n\nA simplified setting DTO that only includes the key and value,\nused for public display settings accessible to all authenticated users.", @@ -20064,6 +23489,41 @@ } } }, + "SearchResultPreviewDto": { + "type": "object", + "description": "Preview data for search results", + "properties": { + "description": { + "type": [ + "string", + "null" + ], + "description": "Short description" + }, + "genres": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Genres" + }, + "rating": { + "type": [ + "number", + "null" + ], + "format": "double", + "description": "Rating" + }, + "status": { + "type": [ + "string", + "null" + ], + "description": "Status string" + } + } + }, "SearchSeriesRequest": { "type": "object", "description": "Search series request", @@ -20825,6 +24285,34 @@ "custom" ] }, + "SeriesUpdateResponse": { + "type": "object", + "description": "Response for series update", + "required": [ + "id", + "title", + "updatedAt" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Series ID", + "example": "550e8400-e29b-41d4-a716-446655440002" + }, + "title": { + "type": "string", + "description": "Updated title", + "example": "Batman: Year One" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Last update timestamp", + "example": "2024-01-15T10:30:00Z" + } + } + }, "SetPreferenceRequest": { "type": "object", "description": "Request to set a single preference value", @@ -21268,6 +24756,24 @@ } } }, + "SkippedField": { + "type": "object", + "description": "A field that was skipped during apply", + "required": [ + "field", + "reason" + ], + "properties": { + "field": { + "type": "string", + "description": "Field name" + }, + "reason": { + "type": "string", + "description": "Reason for skipping" + } + } + }, "SmartBookConfig": { "type": "object", "description": "Configuration for smart book naming strategy", @@ -22024,23 +25530,48 @@ }, { "type": "object", - "description": "Generate thumbnail for a series (from first book's cover)", + "description": "Generate thumbnail for a series (from first book's cover)", + "required": [ + "series_id", + "type" + ], + "properties": { + "force": { + "type": "boolean" + }, + "series_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "generate_series_thumbnail" + ] + } + } + }, + { + "type": "object", + "description": "Generate thumbnails for series in a scope (library or all)\nThis is a fan-out task that enqueues individual GenerateSeriesThumbnail tasks", "required": [ - "series_id", "type" ], "properties": { "force": { "type": "boolean" }, - "series_id": { - "type": "string", + "library_id": { + "type": [ + "string", + "null" + ], "format": "uuid" }, "type": { "type": "string", "enum": [ - "generate_series_thumbnail" + "generate_series_thumbnails" ] } } @@ -22144,6 +25675,38 @@ ] } } + }, + { + "type": "object", + "description": "Auto-match metadata for a series using a plugin", + "required": [ + "series_id", + "plugin_id", + "type" + ], + "properties": { + "plugin_id": { + "type": "string", + "format": "uuid" + }, + "series_id": { + "type": "string", + "format": "uuid" + }, + "source_scope": { + "type": [ + "string", + "null" + ], + "description": "Source scope that triggered this task (for tracking)" + }, + "type": { + "type": "string", + "enum": [ + "plugin_auto_match" + ] + } + } } ], "description": "Task types supported by the distributed task queue" @@ -22530,6 +26093,167 @@ } } }, + "UpdateBookMetadataLocksRequest": { + "type": "object", + "description": "Request to update book metadata locks\n\nAll fields are optional. Only provided fields will be updated.", + "properties": { + "blackAndWhiteLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock black_and_white" + }, + "coloristLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock colorist" + }, + "countLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock count" + }, + "coverArtistLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock cover artist" + }, + "dayLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock day" + }, + "editorLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock editor" + }, + "formatDetailLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock format_detail" + }, + "genreLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock genre" + }, + "imprintLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock imprint" + }, + "inkerLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock inker" + }, + "isbnsLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock isbns" + }, + "languageIsoLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock language_iso" + }, + "lettererLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock letterer" + }, + "mangaLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock manga" + }, + "monthLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock month" + }, + "pencillerLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock penciller" + }, + "publisherLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock publisher" + }, + "summaryLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock summary", + "example": true + }, + "volumeLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock volume" + }, + "webLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock web URL" + }, + "writerLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock writer" + }, + "yearLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock year" + } + } + }, "UpdateLibraryRequest": { "type": "object", "description": "Update library request\n\nNote: series_strategy and series_config are immutable after library creation.\nbook_strategy, book_config, number_strategy, and number_config can be updated.", @@ -22755,6 +26479,114 @@ } } }, + "UpdatePluginRequest": { + "type": "object", + "description": "Request to update a plugin", + "properties": { + "args": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Updated command arguments" + }, + "command": { + "type": [ + "string", + "null" + ], + "description": "Updated command" + }, + "config": { + "description": "Updated configuration" + }, + "credentialDelivery": { + "type": [ + "string", + "null" + ], + "description": "Updated credential delivery method" + }, + "credentials": { + "description": "Updated credentials (set to null to clear)" + }, + "description": { + "type": [ + "string", + "null" + ], + "description": "Updated description" + }, + "displayName": { + "type": [ + "string", + "null" + ], + "description": "Updated display name", + "example": "MangaBaka v2" + }, + "env": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/EnvVarDto" + }, + "description": "Updated environment variables" + }, + "libraryIds": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Updated library IDs (empty = all libraries)" + }, + "permissions": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Updated permissions" + }, + "rateLimitRequestsPerMinute": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Updated rate limit in requests per minute (Some(None) = remove limit)", + "example": 60 + }, + "scopes": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Updated scopes" + }, + "workingDirectory": { + "type": [ + "string", + "null" + ], + "description": "Updated working directory" + } + } + }, "UpdateProgressRequest": { "type": "object", "description": "Request to update reading progress for a book", @@ -23464,6 +27296,14 @@ "name": "Settings", "description": "Runtime configuration settings (admin only)" }, + { + "name": "Plugins", + "description": "Admin-managed external plugin processes" + }, + { + "name": "Plugin Actions", + "description": "Plugin action discovery and execution for metadata fetching" + }, { "name": "Metrics", "description": "Application metrics and statistics" @@ -23550,6 +27390,8 @@ "tags": [ "Admin", "Settings", + "Plugins", + "Plugin Actions", "Metrics", "Filesystem", "Duplicates", diff --git a/docs/docs/development/architecture.md b/docs/dev/contributing/architecture.md similarity index 86% rename from docs/docs/development/architecture.md rename to docs/dev/contributing/architecture.md index b032f212..a675cb07 100644 --- a/docs/docs/development/architecture.md +++ b/docs/dev/contributing/architecture.md @@ -40,32 +40,32 @@ Format parsers are pluggable and isolated: ``` ┌─────────────────────────────────────────┐ │ HTTP API Layer │ -│ (Axum Web Framework) │ -└──────────────┬──────────────────────────┘ - │ -┌──────────────▼──────────────────────────┐ -│ Request Handlers │ -│ (Libraries, Books, Series, Users) │ -└──────────────┬──────────────────────────┘ - │ -┌──────────────▼──────────────────────────┐ -│ Repository Layer │ -│ (Database Abstraction) │ -└──────────────┬──────────────────────────┘ - │ -┌──────────────▼──────────────────────────┐ -│ Database Layer │ -│ (SeaORM - SQLite/PostgreSQL) │ +│ (Axum Web Framework) │ +└───────────────────┬─────────────────────┘ + │ +┌───────────────────▼─────────────────────┐ +│ Request Handlers │ +│ (Libraries, Books, Series, Users) │ +└───────────────────┬─────────────────────┘ + │ +┌───────────────────▼─────────────────────┐ +│ Repository Layer │ +│ (Database Abstraction) │ +└───────────────────┬─────────────────────┘ + │ +┌───────────────────▼─────────────────────┐ +│ Database Layer │ +│ (SeaORM - SQLite/PostgreSQL) │ └─────────────────────────────────────────┘ ┌─────────────────────────────────────────┐ -│ Scanner System │ -│ (File Detection & Analysis) │ -└──────────────┬──────────────────────────┘ - │ -┌──────────────▼──────────────────────────┐ -│ Parser System │ -│ (CBZ, CBR, EPUB, PDF) │ +│ Scanner System │ +│ (File Detection & Analysis) │ +└───────────────────┬─────────────────────┘ + │ +┌───────────────────▼─────────────────────┐ +│ Parser System │ +│ (CBZ, CBR, EPUB, PDF) │ └─────────────────────────────────────────┘ ``` @@ -347,6 +347,6 @@ tests/ ## Next Steps -- Review [deployment options](../deployment) -- Learn about [API usage](../api) -- Explore [configuration options](../configuration) +- Review [deployment options](/docs/deployment) +- Learn about [API usage](/docs/api) +- Explore [configuration options](/docs/configuration) diff --git a/docs/docs/development/development.md b/docs/dev/contributing/development.md similarity index 98% rename from docs/docs/development/development.md rename to docs/dev/contributing/development.md index f3abdcb7..7f55dc7d 100644 --- a/docs/docs/development/development.md +++ b/docs/dev/contributing/development.md @@ -365,5 +365,5 @@ cargo build --release ## Next Steps - Review the [Architecture](./architecture) documentation -- Check the [API Documentation](../api) for endpoint details -- See [Configuration](../configuration) for available options +- Check the [API Documentation](/docs/api) for endpoint details +- See [Configuration](/docs/configuration) for available options diff --git a/docs/docs/development/migrations.md b/docs/dev/contributing/migrations.md similarity index 100% rename from docs/docs/development/migrations.md rename to docs/dev/contributing/migrations.md diff --git a/docs/dev/intro.md b/docs/dev/intro.md new file mode 100644 index 00000000..5c06bea9 --- /dev/null +++ b/docs/dev/intro.md @@ -0,0 +1,32 @@ +--- +sidebar_position: 1 +--- + +# Developer Guide + +Welcome to the Codex developer documentation. This section covers everything you need to contribute to Codex or build plugins. + +## Topics + +### Plugins + +Build custom plugins to extend Codex functionality: + +- [Plugin Overview](./plugins/overview.md) - Introduction to the plugin system +- [Writing Plugins](./plugins/writing-plugins.md) - Step-by-step guide to creating plugins +- [Plugin Protocol](./plugins/protocol.md) - JSON-RPC protocol specification +- [Plugin SDK](./plugins/sdk.md) - TypeScript SDK reference + +### Contributing + +Help improve Codex itself: + +- [Development Setup](./contributing/development.md) - Set up your development environment +- [Architecture](./contributing/architecture.md) - Understanding the codebase structure +- [Database Migrations](./contributing/migrations.md) - Working with SeaORM migrations + +## Quick Links + +- [GitHub Repository](https://github.com/AshDevFr/codex) +- [Issue Tracker](https://github.com/AshDevFr/codex/issues) +- [API Reference](/docs/api/codex-api) diff --git a/docs/dev/plugins/overview.md b/docs/dev/plugins/overview.md new file mode 100644 index 00000000..75c69e21 --- /dev/null +++ b/docs/dev/plugins/overview.md @@ -0,0 +1,127 @@ +# Plugins Overview + +Codex supports a plugin system that allows external processes to provide metadata, sync reading progress, and more. Plugins communicate with Codex via JSON-RPC 2.0 over stdio. + +## What Are Plugins? + +Plugins are external processes that Codex spawns and communicates with. They can be written in any language (TypeScript, Python, Rust, etc.) and provide various capabilities: + +- **Metadata Providers**: Search and fetch metadata from external sources like MangaBaka, AniList, ComicVine +- **Sync Providers** (coming soon): Sync reading progress with external services +- **Recommendation Providers** (coming soon): Provide personalized recommendations + +## Architecture + +``` +┌───────────────────────────────────────────────────────────────────┐ +│ CODEX SERVER │ +├───────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Plugin Manager │ │ +│ │ │ │ +│ │ • Spawns plugin processes (command + args) │ │ +│ │ • Communicates via stdio/JSON-RPC │ │ +│ │ • Enforces RBAC permissions on writes │ │ +│ │ • Monitors health, auto-disables on failures │ │ +│ └──────────────────────────┬──────────────────────────────────┘ │ +│ │ │ +│ ┌───────────────┼───────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ MangaBaka │ │ AniList │ │ Custom │ │ +│ │ Plugin │ │ Plugin │ │ Plugin │ │ +│ │ │ │ │ │ │ │ +│ │ stdin/stdout│ │ stdin/stdout│ │ stdin/stdout│ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +└───────────────────────────────────────────────────────────────────┘ +``` + +## Available Plugins + +### Official Plugins + +| Plugin | Package | Description | Status | +|--------|---------|-------------|--------| +| MangaBaka Metadata | `@codex/plugin-metadata-mangabaka` | Aggregated manga metadata from multiple sources | Available | +| Echo Metadata | `@codex/plugin-metadata-echo` | Test plugin for development | Available | + +### Community Plugins + +Coming soon! See [Writing Plugins](./writing-plugins.md) to create your own. + +## Getting Started + +### Using npx (Recommended) + +The easiest way to run plugins is via `npx`, which downloads and runs the plugin automatically: + +1. Navigate to **Admin Settings** → **Plugins** +2. Click **Add Plugin** +3. Configure: + - **Command**: `npx` + - **Arguments**: `-y @codex/plugin-metadata-mangabaka@1.0.0` +4. Add your credentials (API keys, etc.) +5. Click **Save** and **Enable** + +### npx Options + +| Option | Example | Description | +|--------|---------|-------------| +| Latest version | `-y @codex/plugin-metadata-mangabaka` | Always uses latest | +| Specific version | `-y @codex/plugin-metadata-mangabaka@1.0.0` | Pins to exact version | +| Version range | `-y @codex/plugin-metadata-mangabaka@^1.0.0` | Allows compatible updates | +| Faster startup | `-y --prefer-offline @codex/plugin-metadata-mangabaka@1.0.0` | Skips version check if cached | + +**Flags explained:** +- `-y` (or `--yes`): Auto-confirms installation, required for non-interactive environments like containers +- `--prefer-offline`: Uses cached version without checking npm registry, faster startup + +### Container Deployment + +For containers, use `--prefer-offline` with a pinned version for fast, predictable startup: + +``` +Command: npx +Arguments: -y --prefer-offline @codex/plugin-metadata-mangabaka@1.0.0 +``` + +You can pre-warm the npx cache in your Dockerfile: + +```dockerfile +# Pre-cache plugin during image build +RUN npx -y @codex/plugin-metadata-mangabaka@1.0.0 --version || true +``` + +### Manual Installation + +For maximum performance, install globally and reference directly: + +```bash +npm install -g @codex/plugin-metadata-mangabaka +``` + +Then configure: +- **Command**: `codex-plugin-metadata-mangabaka` +- **Arguments**: (none needed) + +## Plugin Lifecycle + +1. **Spawn**: When a plugin is needed, Codex spawns it as a child process +2. **Initialize**: Codex sends an `initialize` request, plugin responds with its manifest +3. **Requests**: Codex sends requests (search, get, match), plugin responds +4. **Health Monitoring**: Failed requests are tracked; plugins auto-disable after repeated failures +5. **Shutdown**: On server shutdown or plugin disable, Codex sends `shutdown` request + +## Security + +- **RBAC**: Plugins have configurable permissions (what metadata they can write) +- **Process Isolation**: Plugins run as separate processes +- **Health Monitoring**: Failing plugins are automatically disabled +- **Credential Encryption**: API keys are encrypted at rest + +## Next Steps + +- [Writing Plugins](./writing-plugins.md) - Create your own plugin +- [Plugin Protocol](./protocol.md) - Technical protocol specification +- [Plugin SDK](./sdk.md) - TypeScript SDK documentation diff --git a/docs/dev/plugins/protocol.md b/docs/dev/plugins/protocol.md new file mode 100644 index 00000000..7e581e09 --- /dev/null +++ b/docs/dev/plugins/protocol.md @@ -0,0 +1,401 @@ +# Plugin Protocol + +This document describes the JSON-RPC 2.0 protocol used for communication between Codex and plugins. + +## Overview + +Plugins communicate with Codex via JSON-RPC 2.0 over stdio: + +- **stdin**: Receives JSON-RPC requests from Codex (one request per line) +- **stdout**: Sends JSON-RPC responses to Codex (one response per line) +- **stderr**: Logging output (visible in Codex logs) + +## Message Format + +### Request + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "metadata/search", + "params": { + "query": "naruto", + "limit": 10 + } +} +``` + +### Success Response + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "results": [...], + "page": 1, + "hasNextPage": true + } +} +``` + +### Error Response + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32001, + "message": "Rate limited, retry after 60s", + "data": { + "retryAfterSeconds": 60 + } + } +} +``` + +## Methods + +### initialize + +Called when Codex first connects to the plugin. Returns the plugin manifest and optionally receives credentials and configuration. + +**Request:** +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "credentials": { + "api_key": "your-api-key" + }, + "config": { + "base_url": "https://api.example.com" + } + } +} +``` + +The `params` object is optional and depends on the plugin's **credential delivery** setting: + +| Delivery Method | Value | Behavior | +|-----------------|-------|----------| +| Environment Variables | `env` | Credentials passed as env vars (e.g., `API_KEY`). No `credentials` in params. | +| Initialize Message | `init_message` | Credentials passed in `params.credentials`. | +| Both | `both` | Credentials passed both as env vars and in `params.credentials`. | + +**Response:** +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "name": "my-plugin", + "displayName": "My Plugin", + "version": "1.0.0", + "description": "A metadata provider", + "author": "Your Name", + "protocolVersion": "1.0", + "capabilities": { + "seriesMetadataProvider": true + } + } +} +``` + +### ping + +Health check method. Used by Codex to verify the plugin is responsive. + +**Request:** +```json +{ + "jsonrpc": "2.0", + "id": 2, + "method": "ping" +} +``` + +**Response:** +```json +{ + "jsonrpc": "2.0", + "id": 2, + "result": "pong" +} +``` + +### shutdown + +Called when Codex is shutting down or disabling the plugin. Plugins should clean up resources and exit. + +**Request:** +```json +{ + "jsonrpc": "2.0", + "id": 3, + "method": "shutdown" +} +``` + +**Response:** +```json +{ + "jsonrpc": "2.0", + "id": 3, + "result": null +} +``` + +### metadata/search + +Search for metadata by query string. + +**Request:** +```json +{ + "jsonrpc": "2.0", + "id": 4, + "method": "metadata/search", + "params": { + "query": "one piece", + "limit": 10 + } +} +``` + +**Response:** +```json +{ + "jsonrpc": "2.0", + "id": 4, + "result": { + "results": [ + { + "externalId": "12345", + "title": "One Piece", + "summary": "A pirate adventure...", + "year": 1997, + "coverUrl": "https://example.com/cover.jpg", + "status": "ongoing", + "score": 95, + "providerData": { + "url": "https://example.com/series/12345" + } + } + ], + "totalResults": 1, + "page": 1, + "hasNextPage": false + } +} +``` + +### metadata/get + +Get full metadata for an external ID. + +**Request:** +```json +{ + "jsonrpc": "2.0", + "id": 5, + "method": "metadata/get", + "params": { + "externalId": "12345" + } +} +``` + +**Response:** +```json +{ + "jsonrpc": "2.0", + "id": 5, + "result": { + "externalId": "12345", + "titles": [ + { "value": "One Piece", "language": "en", "primary": true }, + { "value": "ワンピース", "language": "ja" } + ], + "summary": "A long, epic pirate adventure...", + "status": "ongoing", + "year": 1997, + "coverUrl": "https://example.com/cover.jpg", + "genres": ["Action", "Adventure", "Comedy"], + "tags": ["Pirates", "Superpowers", "Long Running"], + "authors": [ + { "name": "Eiichiro Oda", "role": "author" } + ], + "publisher": "Shueisha", + "rating": 9.5, + "ratingCount": 100000, + "externalLinks": [ + { "name": "MangaBaka", "url": "https://mangabaka.org/series/12345" } + ] + } +} +``` + +### metadata/match + +Find best match for existing content (auto-matching). + +**Request:** +```json +{ + "jsonrpc": "2.0", + "id": 6, + "method": "metadata/match", + "params": { + "title": "One Piece", + "year": 1997, + "author": "Eiichiro Oda" + } +} +``` + +**Response:** +```json +{ + "jsonrpc": "2.0", + "id": 6, + "result": { + "match": { + "externalId": "12345", + "title": "One Piece", + "year": 1997, + "score": 95 + }, + "confidence": 98, + "alternatives": [ + { + "externalId": "67890", + "title": "One Piece: Strong World", + "score": 60 + } + ] + } +} +``` + +## Data Types + +### SearchResult + +```typescript +interface SearchResult { + externalId: string; // Provider's ID for this item + title: string; // Primary display title + alternateTitles?: Title[]; + year?: number; + coverUrl?: string; + summary?: string; + status?: "ongoing" | "completed" | "hiatus" | "cancelled" | "unknown"; + score?: number; // Relevance score (0-100) + providerData?: object; // Passed to metadata/get +} +``` + +### SeriesMetadata + +```typescript +interface SeriesMetadata { + externalId: string; + titles: Title[]; + summary?: string; + status?: "ongoing" | "completed" | "hiatus" | "cancelled" | "unknown"; + year?: number; + yearEnd?: number; + coverUrl?: string; + bannerUrl?: string; + genres?: string[]; + tags?: string[]; + contentRating?: string; + authors?: Person[]; + artists?: Person[]; + publisher?: string; + originalLanguage?: string; + country?: string; + rating?: number; // 0-10 scale + ratingCount?: number; + externalLinks?: ExternalLink[]; + providerData?: object; +} + +interface Title { + value: string; + language?: string; // ISO 639-1 code + primary?: boolean; +} + +interface Person { + name: string; + role?: string; +} + +interface ExternalLink { + name: string; + url: string; +} +``` + +## Error Codes + +### Standard JSON-RPC Errors + +| Code | Message | Description | +|------|---------|-------------| +| -32700 | Parse error | Invalid JSON | +| -32600 | Invalid Request | Not a valid JSON-RPC request | +| -32601 | Method not found | Method doesn't exist | +| -32602 | Invalid params | Invalid method parameters | +| -32603 | Internal error | Internal plugin error | + +### Plugin-Specific Errors + +| Code | Message | Description | +|------|---------|-------------| +| -32001 | Rate limited | API rate limit exceeded | +| -32002 | Not found | Resource not found | +| -32003 | Auth failed | Authentication failed | +| -32004 | API error | External API error | +| -32005 | Config error | Plugin configuration error | + +## Lifecycle + +``` +Codex Plugin Process + │ │ + │─── spawn(command, args, env) ───────────────────────▶│ + │ │ + │◀─────────────────── process starts ─────────────────│ + │ │ + │─── {"method":"initialize"} ─────────────────────────▶│ + │◀─── {"result": manifest} ───────────────────────────│ + │ │ + │─── {"method":"ping"} ───────────────────────────────▶│ + │◀─── {"result": "pong"} ─────────────────────────────│ + │ │ + │─── {"method":"metadata/search",...} ────────────────▶│ + │◀─── {"result": [...]} ──────────────────────────────│ + │ │ + │ ... more requests ... │ + │ │ + │─── {"method":"shutdown"} ────────────────────────────▶│ + │◀─── {"result": null} ───────────────────────────────│ + │ │ + │ process exits +``` + +## Best Practices + +1. **Never write to stdout except for JSON-RPC responses** +2. **Use stderr for all logging** +3. **Handle unknown methods gracefully** - return METHOD_NOT_FOUND error +4. **Include request ID in responses** - even for errors +5. **Exit cleanly on shutdown** - clean up resources, then exit +6. **Handle malformed requests** - don't crash on bad input diff --git a/docs/dev/plugins/sdk.md b/docs/dev/plugins/sdk.md new file mode 100644 index 00000000..02946584 --- /dev/null +++ b/docs/dev/plugins/sdk.md @@ -0,0 +1,389 @@ +# Plugin SDK + +The `@codex/plugin-sdk` package provides TypeScript types, utilities, and a server framework for building Codex plugins. + +## Installation + +```bash +npm install @codex/plugin-sdk +``` + +## Quick Example + +```typescript +import { + createSeriesMetadataPlugin, + type SeriesMetadataProvider, + type PluginManifest, +} from "@codex/plugin-sdk"; + +const manifest = { + name: "metadata-my-plugin", + displayName: "My Metadata Plugin", + version: "1.0.0", + description: "A metadata provider", + author: "Your Name", + protocolVersion: "1.0", + capabilities: { seriesMetadataProvider: true }, +} as const satisfies PluginManifest & { capabilities: { seriesMetadataProvider: true } }; + +const provider: SeriesMetadataProvider = { + async search(params) { + return { results: [] }; + }, + async get(params) { + return { + externalId: params.externalId, + externalUrl: `https://example.com/${params.externalId}`, + alternateTitles: [], + genres: [], + tags: [], + authors: [], + artists: [], + externalLinks: [], + }; + }, +}; + +createSeriesMetadataPlugin({ manifest, provider }); +``` + +## API Reference + +### createSeriesMetadataPlugin + +Creates and starts a series metadata plugin server that handles JSON-RPC communication. + +```typescript +function createSeriesMetadataPlugin(options: SeriesMetadataPluginOptions): void; + +interface SeriesMetadataPluginOptions { + manifest: PluginManifest & { capabilities: { seriesMetadataProvider: true } }; + provider: SeriesMetadataProvider; + onInitialize?: (params: InitializeParams) => void | Promise; + logLevel?: "debug" | "info" | "warn" | "error"; +} +``` + +### SeriesMetadataProvider + +Interface for implementing series metadata providers: + +```typescript +interface SeriesMetadataProvider { + search(params: MetadataSearchParams): Promise; + get(params: MetadataGetParams): Promise; + match?(params: MetadataMatchParams): Promise; +} +``` + +### createLogger + +Creates a logger that writes to stderr (safe for plugins). + +```typescript +function createLogger(options: LoggerOptions): Logger; + +interface LoggerOptions { + name: string; + level?: "debug" | "info" | "warn" | "error"; + timestamps?: boolean; +} + +interface Logger { + debug(message: string, data?: unknown): void; + info(message: string, data?: unknown): void; + warn(message: string, data?: unknown): void; + error(message: string, data?: unknown): void; +} +``` + +**Example:** + +```typescript +const logger = createLogger({ name: "metadata-my-plugin", level: "debug" }); + +logger.info("Plugin started"); +logger.debug("Processing request", { params }); +logger.error("Request failed", error); +``` + +## Error Classes + +### RateLimitError + +Thrown when rate limited by an external API. + +```typescript +import { RateLimitError } from "@codex/plugin-sdk"; + +if (response.status === 429) { + throw new RateLimitError(60); // Retry after 60 seconds +} +``` + +### NotFoundError + +Thrown when a requested resource doesn't exist. + +```typescript +import { NotFoundError } from "@codex/plugin-sdk"; + +if (response.status === 404) { + throw new NotFoundError("Series not found"); +} +``` + +### AuthError + +Thrown when authentication fails. + +```typescript +import { AuthError } from "@codex/plugin-sdk"; + +if (response.status === 401) { + throw new AuthError("Invalid API key"); +} +``` + +### ApiError + +Thrown for generic API errors. + +```typescript +import { ApiError } from "@codex/plugin-sdk"; + +if (!response.ok) { + throw new ApiError(`API error: ${response.status}`, response.status); +} +``` + +### ConfigError + +Thrown when the plugin is misconfigured. + +```typescript +import { ConfigError } from "@codex/plugin-sdk"; + +if (!apiKey) { + throw new ConfigError("api_key credential is required"); +} +``` + +## Types + +### PluginManifest + +```typescript +interface PluginManifest { + name: string; // Unique identifier (e.g., "metadata-myplugin") + displayName: string; + version: string; + description: string; + author: string; + homepage?: string; + icon?: string; + protocolVersion: "1.0"; + capabilities: PluginCapabilities; + requiredCredentials?: CredentialField[]; +} + +interface PluginCapabilities { + seriesMetadataProvider?: boolean; + syncProvider?: boolean; + recommendationProvider?: boolean; +} + +interface CredentialField { + key: string; + label: string; + description?: string; + required: boolean; + sensitive: boolean; + type: "text" | "password" | "url"; + placeholder?: string; +} +``` + +### MetadataSearchParams / MetadataSearchResponse + +```typescript +interface MetadataSearchParams { + query: string; + limit?: number; + cursor?: string; +} + +interface MetadataSearchResponse { + results: SearchResult[]; + nextCursor?: string; +} + +interface SearchResult { + externalId: string; + title: string; + alternateTitles: string[]; + year?: number; + coverUrl?: string; + relevanceScore: number; // 0.0-1.0 + preview?: SearchResultPreview; +} + +interface SearchResultPreview { + status?: SeriesStatus; + genres?: string[]; + rating?: number; + description?: string; +} +``` + +### MetadataGetParams / PluginSeriesMetadata + +```typescript +interface MetadataGetParams { + externalId: string; +} + +interface PluginSeriesMetadata { + externalId: string; + externalUrl?: string; + title?: string; + alternateTitles: AlternateTitle[]; + summary?: string; + status?: SeriesStatus; + year?: number; + totalBookCount?: number; + language?: string; + ageRating?: number; + readingDirection?: ReadingDirection; + genres: string[]; + tags: string[]; + authors: string[]; + artists: string[]; + publisher?: string; + coverUrl?: string; + bannerUrl?: string; + rating?: ExternalRating; + externalRatings?: ExternalRating[]; + externalLinks: ExternalLink[]; +} + +interface AlternateTitle { + title: string; + language?: string; + titleType?: "english" | "native" | "romaji" | string; +} + +type SeriesStatus = "ongoing" | "ended" | "cancelled" | "hiatus" | "unknown"; +type ReadingDirection = "ltr" | "rtl" | "ttb" | "btt"; +``` + +### MetadataMatchParams / MetadataMatchResponse + +```typescript +interface MetadataMatchParams { + title: string; + year?: number; + author?: string; +} + +interface MetadataMatchResponse { + match: SearchResult | null; + confidence: number; // 0.0-1.0 + alternatives?: SearchResult[]; +} +``` + +### Supporting Types + +```typescript +interface ExternalRating { + score: number; // 0-100 + voteCount?: number; + source: string; +} + +interface ExternalLink { + url: string; + label: string; + linkType?: ExternalLinkType; +} + +type ExternalLinkType = + | "provider" + | "official" + | "social" + | "purchase" + | "info" + | "other"; +``` + +## JSON-RPC Types + +```typescript +interface JsonRpcRequest { + jsonrpc: "2.0"; + id: string | number | null; + method: string; + params?: unknown; +} + +interface JsonRpcSuccessResponse { + jsonrpc: "2.0"; + id: string | number | null; + result: unknown; +} + +interface JsonRpcErrorResponse { + jsonrpc: "2.0"; + id: string | number | null; + error: JsonRpcError; +} + +interface JsonRpcError { + code: number; + message: string; + data?: unknown; +} +``` + +## Error Codes + +```typescript +// Standard JSON-RPC errors +const JSON_RPC_ERROR_CODES = { + PARSE_ERROR: -32700, + INVALID_REQUEST: -32600, + METHOD_NOT_FOUND: -32601, + INVALID_PARAMS: -32602, + INTERNAL_ERROR: -32603, +}; + +// Plugin-specific errors +const PLUGIN_ERROR_CODES = { + RATE_LIMITED: -32001, + NOT_FOUND: -32002, + AUTH_FAILED: -32003, + API_ERROR: -32004, + CONFIG_ERROR: -32005, +}; +``` + +## Initialize Callback + +Use `onInitialize` to receive credentials and configuration: + +```typescript +createSeriesMetadataPlugin({ + manifest, + provider, + onInitialize(params) { + // params.credentials - Credential values (e.g., { api_key: "..." }) + // params.config - Configuration values + if (!params.credentials?.api_key) { + throw new ConfigError("api_key credential is required"); + } + apiKey = params.credentials.api_key; + }, +}); +``` diff --git a/docs/dev/plugins/writing-plugins.md b/docs/dev/plugins/writing-plugins.md new file mode 100644 index 00000000..8ff37221 --- /dev/null +++ b/docs/dev/plugins/writing-plugins.md @@ -0,0 +1,465 @@ +# Writing Plugins + +This guide walks you through creating a Codex metadata plugin from scratch using TypeScript and the official SDK. + +## Prerequisites + +- Node.js 18 or later +- npm or pnpm +- Basic TypeScript knowledge + +## Quick Start + +### 1. Create a New Project + +```bash +mkdir codex-plugin-metadata-myplugin +cd codex-plugin-metadata-myplugin +npm init -y +``` + +### 2. Install Dependencies + +```bash +npm install @codex/plugin-sdk +npm install -D typescript @types/node esbuild +``` + +### 3. Configure TypeScript + +Create `tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} +``` + +Update `package.json`: + +```json +{ + "name": "@codex/plugin-metadata-myplugin", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --sourcemap", + "start": "node dist/index.js", + "typecheck": "tsc --noEmit" + } +} +``` + +### 4. Write Your Plugin + +Create `src/index.ts`: + +```typescript +import { + createMetadataPlugin, + type MetadataProvider, + type MetadataSearchParams, + type MetadataSearchResponse, + type MetadataGetParams, + type PluginSeriesMetadata, + type PluginManifest, + type MetadataContentType, +} from "@codex/plugin-sdk"; + +// Define your plugin manifest +const manifest = { + name: "metadata-myplugin", + displayName: "My Metadata Plugin", + version: "1.0.0", + description: "A custom metadata provider", + author: "Your Name", + protocolVersion: "1.0", + capabilities: { + metadataProvider: ["series"] as MetadataContentType[], + }, + // Optional: credentials your plugin needs + requiredCredentials: [ + { + key: "api_key", + label: "API Key", + description: "Your API key for the metadata service", + required: true, + sensitive: true, + type: "password", + }, + ], +} as const satisfies PluginManifest & { capabilities: { metadataProvider: MetadataContentType[] } }; + +// Implement the MetadataProvider interface +const provider: MetadataProvider = { + async search(params: MetadataSearchParams): Promise { + // Implement your search logic + const results = await fetchResults(params.query); + + return { + results: results.map(r => ({ + externalId: r.id, + title: r.title, + alternateTitles: [], + year: r.year, + coverUrl: r.cover, + relevanceScore: 0.9, + preview: { + status: r.status, + genres: r.genres.slice(0, 3), + description: r.summary?.slice(0, 200), + }, + })), + }; + }, + + async get(params: MetadataGetParams): Promise { + // Implement your get logic + const series = await fetchSeries(params.externalId); + + return { + externalId: series.id, + externalUrl: `https://example.com/series/${series.id}`, + title: series.title, + alternateTitles: [ + { title: series.nativeTitle, language: "ja", titleType: "native" }, + ], + summary: series.description, + status: series.status, + year: series.year, + genres: series.genres, + tags: series.tags, + authors: series.authors, + artists: series.artists, + externalLinks: [], + }; + }, +}; + +// Start the plugin +createMetadataPlugin({ manifest, provider }); +``` + +### 5. Build and Test + +```bash +npm run build + +# Test manually +echo '{"jsonrpc":"2.0","id":1,"method":"initialize"}' | node dist/index.js +``` + +## Plugin Manifest + +The manifest describes your plugin's capabilities and requirements: + +```typescript +interface PluginManifest { + // Required + name: string; // Unique identifier (lowercase, alphanumeric, hyphens) + displayName: string; // Human-readable name + version: string; // Semver version + description: string; // Short description + author: string; // Author name + protocolVersion: "1.0"; // Protocol version + + // Capabilities + capabilities: { + metadataProvider?: MetadataContentType[]; // Content types: ["series"] or ["series", "book"] + syncProvider?: boolean; // Can sync reading progress (future) + recommendationProvider?: boolean; // Can provide recommendations (future) + }; + + // Optional + homepage?: string; // Documentation URL + icon?: string; // Icon URL + requiredCredentials?: CredentialField[]; // API keys, etc. +} +``` + +## MetadataProvider Interface + +Plugins must implement the `MetadataProvider` interface: + +```typescript +interface MetadataProvider { + search(params: MetadataSearchParams): Promise; + get(params: MetadataGetParams): Promise; + match?(params: MetadataMatchParams): Promise; +} +``` + +The SDK automatically routes scoped method calls to your provider: +- `metadata/series/search` → `provider.search()` +- `metadata/series/get` → `provider.get()` +- `metadata/series/match` → `provider.match()` + +### search + +Search for metadata by query string: + +```typescript +async search(params: MetadataSearchParams): Promise { + // params.query - Search query string + // params.limit - Maximum results to return + // params.cursor - Pagination cursor from previous response + + return { + results: [ + { + externalId: "123", + title: "Series Title", + alternateTitles: ["Alt Title"], + year: 2024, + coverUrl: "https://example.com/cover.jpg", + relevanceScore: 0.95, // 0.0-1.0 + preview: { + status: "ongoing", + genres: ["Action", "Adventure"], + rating: 8.5, + description: "Brief description...", + }, + }, + ], + nextCursor: "page2", // Optional: for pagination + }; +} +``` + +### get + +Get full metadata for an external ID: + +```typescript +async get(params: MetadataGetParams): Promise { + // params.externalId - ID from search results + + return { + externalId: "123", + externalUrl: "https://example.com/series/123", + title: "Series Title", + alternateTitles: [ + { title: "日本語タイトル", language: "ja", titleType: "native" }, + { title: "Romanized Title", language: "ja-Latn", titleType: "romaji" }, + ], + summary: "Full description...", + status: "ongoing", + year: 2024, + genres: ["Action", "Adventure"], + tags: ["Fantasy", "Magic"], + authors: ["Author Name"], + artists: ["Artist Name"], + publisher: "Publisher Name", + rating: { score: 85, voteCount: 1000, source: "example" }, + externalLinks: [ + { url: "https://example.com/123", label: "Example", linkType: "provider" }, + ], + }; +} +``` + +### match (Optional) + +Find best match for existing content (used for auto-matching): + +```typescript +async match(params: MetadataMatchParams): Promise { + // params.title - Title to match + // params.year - Year hint + // params.author - Author hint + + return { + match: bestResult, // Best match or null + confidence: 0.85, // 0.0-1.0 confidence score + alternatives: [...], // Other possible matches if confidence is low + }; +} +``` + +## Error Handling + +Use SDK error classes for proper error reporting: + +```typescript +import { + RateLimitError, + NotFoundError, + AuthError, + ApiError, + ConfigError, +} from "@codex/plugin-sdk"; + +// Rate limited by API +if (response.status === 429) { + const retryAfter = response.headers.get("Retry-After") || "60"; + throw new RateLimitError(parseInt(retryAfter, 10)); +} + +// Resource not found +if (response.status === 404) { + throw new NotFoundError("Series not found"); +} + +// Authentication failed +if (response.status === 401) { + throw new AuthError("Invalid API key"); +} + +// Generic API error +if (!response.ok) { + throw new ApiError(`API error: ${response.status}`, response.status); +} + +// Configuration error +if (!apiKey) { + throw new ConfigError("api_key credential is required"); +} +``` + +## Logging + +Always log to stderr (stdout is reserved for JSON-RPC): + +```typescript +import { createLogger } from "@codex/plugin-sdk"; + +const logger = createLogger({ name: "metadata-myplugin", level: "info" }); + +logger.debug("Processing request", { params }); +logger.info("Search completed", { resultCount: 10 }); +logger.warn("Rate limit approaching"); +logger.error("Request failed", error); + +// NEVER use console.log() - it goes to stdout and breaks the protocol! +// Instead use: +console.error("Debug message"); // This is safe +``` + +## Credential Delivery + +Codex supports three methods for delivering credentials to plugins: + +| Method | Value | Description | +|--------|-------|-------------| +| Environment Variables | `env` | Credentials passed as uppercase env vars (default) | +| Initialize Message | `init_message` | Credentials passed in the `initialize` JSON-RPC request | +| Both | `both` | Credentials passed both ways | + +### Using onInitialize Callback (Recommended) + +Credentials are passed in the `initialize` request params: + +```typescript +import { createMetadataPlugin, ConfigError, type InitializeParams } from "@codex/plugin-sdk"; + +let apiKey: string | undefined; + +createMetadataPlugin({ + manifest, + provider, + onInitialize(params: InitializeParams) { + apiKey = params.credentials?.api_key; + if (!apiKey) { + throw new ConfigError("api_key credential is required"); + } + }, +}); +``` + +### Using Environment Variables + +Credentials are passed as environment variables (credential key in uppercase): + +```typescript +// Credential key "api_key" becomes environment variable "API_KEY" +const apiKey = process.env.API_KEY; + +if (!apiKey) { + throw new ConfigError("API_KEY environment variable is required"); +} +``` + +## Testing Your Plugin + +### Manual Testing + +```bash +# Initialize +echo '{"jsonrpc":"2.0","id":1,"method":"initialize"}' | node dist/index.js + +# Search (note the scoped method name) +echo '{"jsonrpc":"2.0","id":2,"method":"metadata/series/search","params":{"query":"test"}}' | node dist/index.js + +# Ping +echo '{"jsonrpc":"2.0","id":3,"method":"ping"}' | node dist/index.js +``` + +### Unit Tests + +```typescript +import { describe, it, expect } from "vitest"; +import { mapSearchResult } from "./mappers"; + +describe("mappers", () => { + it("should map API response to SearchResult", () => { + const apiResponse = { id: "123", name: "Test" }; + const result = mapSearchResult(apiResponse); + + expect(result.externalId).toBe("123"); + expect(result.title).toBe("Test"); + }); +}); +``` + +## Deploying Your Plugin + +### Local Installation + +1. Build your plugin: `npm run build` +2. In Codex admin UI, add a new plugin: + - Command: `node` + - Args: `/path/to/plugin/dist/index.js` + - Configure credentials + +### Docker + +If running Codex in Docker, mount the plugins directory: + +```yaml +volumes: + - ./my-plugin:/opt/codex/plugins/my-plugin:ro +``` + +Then configure: +- Command: `node` +- Args: `/opt/codex/plugins/my-plugin/dist/index.js` + +## Best Practices + +1. **Handle Rate Limits**: Respect API rate limits, throw `RateLimitError` with retry time +2. **Cache Responses**: Consider caching API responses to reduce load +3. **Normalize Data**: Map external data to standard Codex formats +4. **Graceful Degradation**: Return partial data rather than failing completely +5. **Log Appropriately**: Use debug level for request details, info for summary +6. **Test Thoroughly**: Write unit tests for mappers, integration tests for API client + +## Example Plugins + +- **Echo Plugin**: Simple test plugin - `plugins/metadata-echo/` +- **MangaBaka Plugin**: Full metadata provider - `plugins/metadata-mangabaka/` + +## Next Steps + +- [Plugin Protocol](./protocol.md) - Detailed protocol specification +- [Plugin SDK](./sdk.md) - Full SDK API documentation diff --git a/docs/devSidebar.ts b/docs/devSidebar.ts new file mode 100644 index 00000000..a92c7bca --- /dev/null +++ b/docs/devSidebar.ts @@ -0,0 +1,30 @@ +import type { SidebarsConfig } from "@docusaurus/plugin-content-docs"; + +const devSidebar: SidebarsConfig = { + devSidebar: [ + "intro", + { + type: "category", + label: "Plugins", + collapsed: false, + link: { type: "doc", id: "plugins/overview" }, + items: [ + "plugins/writing-plugins", + "plugins/protocol", + "plugins/sdk", + ], + }, + { + type: "category", + label: "Contributing", + collapsed: false, + items: [ + "contributing/development", + "contributing/architecture", + "contributing/migrations", + ], + }, + ], +}; + +export default devSidebar; diff --git a/docs/docs/custom-metadata.md b/docs/docs/custom-metadata.md index 0e26bb1b..08cb2b00 100644 --- a/docs/docs/custom-metadata.md +++ b/docs/docs/custom-metadata.md @@ -20,7 +20,7 @@ Templates have access to two data sources: - Add personal notes and reviews - Track technical information (resolution, audio, subtitles) -![Custom Metadata on Series](./screenshots/30-settings-server-custom-metadata.png) +![Custom Metadata on Series](../screenshots/settings/server-custom-metadata.png) ## Storing Custom Metadata @@ -99,9 +99,9 @@ The default template displays genres from built-in metadata and custom fields as 4. Use the live preview to test your changes 5. Save your changes -![Custom Metadata Settings](./screenshots/30-settings-server-custom-metadata.png) +![Custom Metadata Settings](../screenshots/settings/server-custom-metadata.png) -![Custom Metadata Template Editor](./screenshots/30-settings-server-custom-metadata-templates.png) +![Custom Metadata Template Editor](../screenshots/settings/server-custom-metadata-templates.png) ## Handlebars Syntax diff --git a/docs/docs/development/_category_.json b/docs/docs/development/_category_.json deleted file mode 100644 index 5783deeb..00000000 --- a/docs/docs/development/_category_.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "label": "Development", - "position": 12, - "link": { - "type": "generated-index", - "description": "Documentation for developers contributing to Codex." - } -} diff --git a/docs/docs/getting-started.mdx b/docs/docs/getting-started.mdx index 4ef99af4..23be1c93 100644 --- a/docs/docs/getting-started.mdx +++ b/docs/docs/getting-started.mdx @@ -64,15 +64,15 @@ sudo mv codex /usr/local/bin/ 1. Open Codex in your browser at `http://localhost:8080` 2. Complete the setup wizard to create your admin account -![Setup Wizard - Create Admin Account](./screenshots/02-setup-wizard-step1-filled.png) +![Setup Wizard - Create Admin Account](../screenshots/setup/wizard-step1-filled.png) 3. Optionally configure initial settings (scanner, thumbnails, etc.) -![Setup Wizard - Configure Settings](./screenshots/04-setup-wizard-step2-basic-settings.png) +![Setup Wizard - Configure Settings](../screenshots/setup/wizard-step2-basic-settings.png) 4. Log in with your new credentials -![Login Screen](./screenshots/52-login-page.png) +![Login Screen](../screenshots/navigation/login-page.png) ## Creating Your First Library @@ -84,17 +84,17 @@ sudo mv codex /usr/local/bin/ - Binary: Use the local path (e.g., `/home/user/comics`) - **Default Reading Direction**: Choose based on your content type -![Add Library - General Settings](./screenshots/07-add-library-general-comics.png) +![Add Library - General Settings](../screenshots/libraries/add-library-general-comics.png) 3. Configure the **Strategy** tab for how series and books are detected -![Add Library - Strategy Settings](./screenshots/08-add-library-strategy-comics.png) +![Add Library - Strategy Settings](../screenshots/libraries/add-library-strategy-comics.png) 4. Set up **Scanning** options: - **Manual**: Scan only when you trigger it - **Automatic**: Schedule regular scans with cron expressions -![Add Library - Scanning Settings](./screenshots/10-add-library-scanning-comics.png) +![Add Library - Scanning Settings](../screenshots/libraries/add-library-scanning-comics.png) 5. Click **Create Library** @@ -130,9 +130,9 @@ Once scanning completes: - **By Series**: Click a library in the sidebar to browse series - **By Books**: Toggle between series and books view -![Home Page](./screenshots/10-home-with-libraries.png) +![Home Page](../screenshots/libraries/home-with-libraries.png) -![All Libraries - Series View](./screenshots/11-all-libraries-series.png) +![All Libraries - Series View](../screenshots/libraries/all-libraries-series.png) ### Reading a Book @@ -141,9 +141,9 @@ Once scanning completes: 3. Progress is saved automatically 4. Access settings via the gear icon in the toolbar -![Comic Reader](./screenshots/20-reader-comic-view.png) +![Comic Reader](../screenshots/reader/comic-view.png) -![Comic Reader Toolbar](./screenshots/20-reader-comic-toolbar.png) +![Comic Reader Toolbar](../screenshots/reader/comic-toolbar.png) ## Upgrading diff --git a/docs/docs/intro.md b/docs/docs/intro.md index f362574a..4b3c2da5 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -6,7 +6,7 @@ slug: / **Codex** is a next-generation digital library server for comics, manga, and ebooks built in Rust. Designed to scale horizontally while remaining simple for homelab deployments, Codex provides a powerful self-hosted solution for managing and reading your digital media collections. -![Codex Home Page](./screenshots/50-home-dashboard.png) +![Codex Home Page](../screenshots/navigation/home-dashboard.png) ## Key Features diff --git a/docs/docs/libraries.md b/docs/docs/libraries.md index 38197725..2b36e845 100644 --- a/docs/docs/libraries.md +++ b/docs/docs/libraries.md @@ -60,15 +60,15 @@ Codex supports multiple scanning strategies for different organizational pattern - **Path**: Filesystem path to the folder - **Default Reading Direction**: Based on content type -![Add Library - General Settings](./screenshots/07-add-library-general-comics.png) +![Add Library - General Settings](../screenshots/libraries/add-library-general-comics.png) 4. Configure the **Strategy** tab for series and book detection -![Add Library - Strategy Settings](./screenshots/08-add-library-strategy-comics.png) +![Add Library - Strategy Settings](../screenshots/libraries/add-library-strategy-comics.png) 5. Configure **Scanning** options (manual or automatic with cron) -![Add Library - Scanning Settings](./screenshots/10-add-library-scanning-comics.png) +![Add Library - Scanning Settings](../screenshots/libraries/add-library-scanning-comics.png) ### Via API @@ -303,7 +303,7 @@ If you move files: Codex can detect duplicate books across libraries using file hashes (SHA-256). -![Duplicate Detection](./screenshots/35-settings-duplicates.png) +![Duplicate Detection](../screenshots/settings/duplicates.png) ### Enable Duplicate Scanning diff --git a/docs/docs/reader-settings.md b/docs/docs/reader-settings.md index 78e0e9c1..4dd147af 100644 --- a/docs/docs/reader-settings.md +++ b/docs/docs/reader-settings.md @@ -10,7 +10,7 @@ Codex provides extensive customization options for reading comics, manga, EPUBs, The comic reader supports CBZ, CBR, and image-based formats with powerful display options. -![Comic Reader](./screenshots/20-reader-comic-view.png) +![Comic Reader](../screenshots/reader/comic-view.png) ### Reading Modes @@ -76,9 +76,9 @@ When reading a series, you can customize display settings specifically for that To reset to global defaults, click **"Reset to global"** in the settings panel. -![Comic Reader Settings](./screenshots/20-reader-comic-settings.png) +![Comic Reader Settings](../screenshots/reader/comic-settings.png) -![Comic Reader Series Settings](./screenshots/20-reader-comic-settings.png) +![Comic Reader Series Settings](../screenshots/reader/comic-settings.png) ### Keyboard Shortcuts @@ -95,9 +95,9 @@ To reset to global defaults, click **"Reset to global"** in the settings panel. The EPUB reader provides a comfortable reading experience for ebooks with extensive typography controls. -![EPUB Reader](./screenshots/21-reader-epub-view.png) +![EPUB Reader](../screenshots/reader/epub-view.png) -![EPUB Reader Toolbar](./screenshots/21-reader-epub-toolbar.png) +![EPUB Reader Toolbar](../screenshots/reader/epub-toolbar.png) ### Themes @@ -141,7 +141,7 @@ Control line height from "Tight" (100%) to "Loose" (250%) for comfortable readin Adjust page margins from 0% (edge-to-edge) to 30% (generous margins). -![EPUB Reader Settings](./screenshots/21-reader-epub-settings.png) +![EPUB Reader Settings](../screenshots/reader/epub-settings.png) ### Navigation Features @@ -149,7 +149,7 @@ Adjust page margins from 0% (edge-to-edge) to 30% (generous margins). - **Bookmarks**: Save and return to specific locations - **Search**: Find text within the book -![EPUB Table of Contents](./screenshots/21-reader-epub-toolbar.png) +![EPUB Table of Contents](../screenshots/reader/epub-toolbar.png) ### Keyboard Shortcuts diff --git a/docs/docs/screenshots/06-setup-complete-dashboard.png b/docs/docs/screenshots/06-setup-complete-dashboard.png deleted file mode 100644 index 4c76649d..00000000 Binary files a/docs/docs/screenshots/06-setup-complete-dashboard.png and /dev/null differ diff --git a/docs/docs/screenshots/07-add-library-general-books.png b/docs/docs/screenshots/07-add-library-general-books.png deleted file mode 100644 index 98274664..00000000 Binary files a/docs/docs/screenshots/07-add-library-general-books.png and /dev/null differ diff --git a/docs/docs/screenshots/10-add-library-scanning-manga.png b/docs/docs/screenshots/10-add-library-scanning-manga.png deleted file mode 100644 index 7e9f94f3..00000000 Binary files a/docs/docs/screenshots/10-add-library-scanning-manga.png and /dev/null differ diff --git a/docs/docs/screenshots/10-home-with-libraries.png b/docs/docs/screenshots/10-home-with-libraries.png deleted file mode 100644 index 80b4b8c7..00000000 Binary files a/docs/docs/screenshots/10-home-with-libraries.png and /dev/null differ diff --git a/docs/docs/screenshots/11-all-libraries-series.png b/docs/docs/screenshots/11-all-libraries-series.png deleted file mode 100644 index dbf4bff4..00000000 Binary files a/docs/docs/screenshots/11-all-libraries-series.png and /dev/null differ diff --git a/docs/docs/screenshots/13-library-detail-series.png b/docs/docs/screenshots/13-library-detail-series.png deleted file mode 100644 index 4bfab183..00000000 Binary files a/docs/docs/screenshots/13-library-detail-series.png and /dev/null differ diff --git a/docs/docs/screenshots/30-settings-server-custom-metadata-templates.png b/docs/docs/screenshots/30-settings-server-custom-metadata-templates.png deleted file mode 100644 index a1ae2c5b..00000000 Binary files a/docs/docs/screenshots/30-settings-server-custom-metadata-templates.png and /dev/null differ diff --git a/docs/docs/screenshots/30-settings-server-custom-metadata.png b/docs/docs/screenshots/30-settings-server-custom-metadata.png deleted file mode 100644 index 31daa9db..00000000 Binary files a/docs/docs/screenshots/30-settings-server-custom-metadata.png and /dev/null differ diff --git a/docs/docs/screenshots/30-settings-server.png b/docs/docs/screenshots/30-settings-server.png deleted file mode 100644 index 5ef01aa4..00000000 Binary files a/docs/docs/screenshots/30-settings-server.png and /dev/null differ diff --git a/docs/docs/screenshots/31-settings-tasks.png b/docs/docs/screenshots/31-settings-tasks.png deleted file mode 100644 index e7478afb..00000000 Binary files a/docs/docs/screenshots/31-settings-tasks.png and /dev/null differ diff --git a/docs/docs/screenshots/32-settings-metrics-tasks.png b/docs/docs/screenshots/32-settings-metrics-tasks.png deleted file mode 100644 index a7282fae..00000000 Binary files a/docs/docs/screenshots/32-settings-metrics-tasks.png and /dev/null differ diff --git a/docs/docs/screenshots/32-settings-metrics.png b/docs/docs/screenshots/32-settings-metrics.png deleted file mode 100644 index 3c1a4442..00000000 Binary files a/docs/docs/screenshots/32-settings-metrics.png and /dev/null differ diff --git a/docs/docs/screenshots/33-settings-users.png b/docs/docs/screenshots/33-settings-users.png deleted file mode 100644 index 30bf9121..00000000 Binary files a/docs/docs/screenshots/33-settings-users.png and /dev/null differ diff --git a/docs/docs/screenshots/37-settings-cleanup.png b/docs/docs/screenshots/37-settings-cleanup.png deleted file mode 100644 index bf64a8af..00000000 Binary files a/docs/docs/screenshots/37-settings-cleanup.png and /dev/null differ diff --git a/docs/docs/screenshots/38-settings-pdf-cache.png b/docs/docs/screenshots/38-settings-pdf-cache.png deleted file mode 100644 index 656271d9..00000000 Binary files a/docs/docs/screenshots/38-settings-pdf-cache.png and /dev/null differ diff --git a/docs/docs/screenshots/50-home-dashboard.png b/docs/docs/screenshots/50-home-dashboard.png deleted file mode 100644 index 0ff86e62..00000000 Binary files a/docs/docs/screenshots/50-home-dashboard.png and /dev/null differ diff --git a/docs/docs/screenshots/51-sidebar-settings-expanded.png b/docs/docs/screenshots/51-sidebar-settings-expanded.png deleted file mode 100644 index 5bc5626e..00000000 Binary files a/docs/docs/screenshots/51-sidebar-settings-expanded.png and /dev/null differ diff --git a/docs/docs/screenshots/53-search-results.png b/docs/docs/screenshots/53-search-results.png deleted file mode 100644 index c792bb80..00000000 Binary files a/docs/docs/screenshots/53-search-results.png and /dev/null differ diff --git a/docs/docs/showcase.md b/docs/docs/showcase.md index c8a299c2..be7848bd 100644 --- a/docs/docs/showcase.md +++ b/docs/docs/showcase.md @@ -10,7 +10,7 @@ Explore the main features of Codex through screenshots and descriptions. The home page provides quick access to your reading activity and recently added content. The **Keep Reading** section lets you continue where you left off, and the **Recently Added** section helps you discover new additions to your library. -![Home Page](./screenshots/50-home-dashboard.png) +![Home Page](../screenshots/navigation/home-dashboard.png) ## Library Management @@ -20,43 +20,43 @@ Create libraries to organize your comics, manga, and ebooks. Configure general s #### Comics Library -![Add Library - Comics General](./screenshots/07-add-library-general-comics.png) +![Add Library - Comics General](../screenshots/libraries/add-library-general-comics.png) -![Add Library - Comics Strategy](./screenshots/08-add-library-strategy-comics.png) +![Add Library - Comics Strategy](../screenshots/libraries/add-library-strategy-comics.png) -![Add Library - Comics Formats](./screenshots/09-add-library-formats-comics.png) +![Add Library - Comics Formats](../screenshots/libraries/add-library-formats-comics.png) -![Add Library - Comics Scanning](./screenshots/10-add-library-scanning-comics.png) +![Add Library - Comics Scanning](../screenshots/libraries/add-library-scanning-comics.png) #### Manga Library -![Add Library - Manga General](./screenshots/07-add-library-general-manga.png) +![Add Library - Manga General](../screenshots/libraries/add-library-general-manga.png) -![Add Library - Manga Strategy](./screenshots/08-add-library-strategy-manga.png) +![Add Library - Manga Strategy](../screenshots/libraries/add-library-strategy-manga.png) -![Add Library - Manga Formats](./screenshots/09-add-library-formats-manga.png) +![Add Library - Manga Formats](../screenshots/libraries/add-library-formats-manga.png) -![Add Library - Manga Scanning](./screenshots/10-add-library-scanning-manga.png) +![Add Library - Manga Scanning](../screenshots/libraries/add-library-scanning-manga.png) #### Books Library -![Add Library - Books General](./screenshots/07-add-library-general-books.png) +![Add Library - Books General](../screenshots/libraries/add-library-general-books.png) -![Add Library - Books Strategy](./screenshots/08-add-library-strategy-books.png) +![Add Library - Books Strategy](../screenshots/libraries/add-library-strategy-books.png) -![Add Library - Books Formats](./screenshots/09-add-library-formats-books.png) +![Add Library - Books Formats](../screenshots/libraries/add-library-formats-books.png) -![Add Library - Books Scanning](./screenshots/10-add-library-scanning-books.png) +![Add Library - Books Scanning](../screenshots/libraries/add-library-scanning-books.png) ### Browsing Libraries View your libraries by series or books with powerful filtering and sorting options. -![All Libraries - Series View](./screenshots/11-all-libraries-series.png) +![All Libraries - Series View](../screenshots/libraries/all-libraries-series.png) -![All Libraries - Books View](./screenshots/12-all-libraries-books.png) +![All Libraries - Books View](../screenshots/libraries/all-libraries-books.png) -![Library Detail - Series](./screenshots/13-library-detail-series.png) +![Library Detail - Series](../screenshots/libraries/library-detail-series.png) ## Series & Book Details @@ -64,13 +64,13 @@ View your libraries by series or books with powerful filtering and sorting optio View comprehensive information about a series including metadata, books, and reading progress. -![Series Details Page](./screenshots/14-series-detail.png) +![Series Details Page](../screenshots/libraries/series-detail.png) ### Book Detail Page View detailed information about a specific book. -![Book Details Page](./screenshots/15-book-detail.png) +![Book Details Page](../screenshots/libraries/book-detail.png) ## Readers @@ -78,137 +78,179 @@ View detailed information about a specific book. A powerful comic reader with customizable display settings. -![Comic Reader](./screenshots/20-reader-comic-view.png) +![Comic Reader](../screenshots/reader/comic-view.png) -![Comic Reader Toolbar](./screenshots/20-reader-comic-toolbar.png) +![Comic Reader Toolbar](../screenshots/reader/comic-toolbar.png) #### Comic Reader Settings Customize reading mode, scale, background, and page layout. -![Comic Reader Settings](./screenshots/20-reader-comic-settings.png) +![Comic Reader Settings](../screenshots/reader/comic-settings.png) ### EPUB Reader A beautiful EPUB reader with extensive typography controls and themes. -![EPUB Reader](./screenshots/21-reader-epub-view.png) +![EPUB Reader](../screenshots/reader/epub-view.png) -![EPUB Reader Toolbar](./screenshots/21-reader-epub-toolbar.png) +![EPUB Reader Toolbar](../screenshots/reader/epub-toolbar.png) #### EPUB Settings Configure fonts, themes, margins, and more. -![EPUB Reader Settings](./screenshots/21-reader-epub-settings.png) +![EPUB Reader Settings](../screenshots/reader/epub-settings.png) ### PDF Reader A native PDF reader with zoom controls and page spread options. -![PDF Reader](./screenshots/22-reader-pdf-view.png) +![PDF Reader](../screenshots/reader/pdf-view.png) -![PDF Reader Toolbar](./screenshots/22-reader-pdf-toolbar.png) +![PDF Reader Toolbar](../screenshots/reader/pdf-toolbar.png) #### PDF Settings Configure zoom levels and page spread modes. -![PDF Reader Settings](./screenshots/22-reader-pdf-settings.png) +![PDF Reader Settings](../screenshots/reader/pdf-settings.png) ## Settings & Administration -### User Profile +### System -#### Account Settings +#### Server -Manage your account details and preferences. +Configure server-wide options and custom metadata templates. -![Profile - Account](./screenshots/39-settings-profile.png) +![Server Settings](../screenshots/settings/server.png) -#### User Preferences +![Custom Metadata](../screenshots/settings/server-custom-metadata.png) -Customize your Codex experience. +![Custom Metadata Templates](../screenshots/settings/server-custom-metadata-templates.png) -![Profile - Preferences](./screenshots/41-settings-profile-preferences.png) +#### Tasks -#### API Keys +Monitor and manage background tasks like scanning and thumbnail generation. -Generate and manage API keys for integrations. +![Task Queue](../screenshots/settings/tasks.png) -![Profile - API Keys](./screenshots/40-settings-profile-api-keys.png) +#### Metrics -### Server Settings +View statistics about your library contents and monitor performance. -#### General Server Settings +![Metrics - Inventory](../screenshots/settings/metrics.png) -Configure server-wide options. +![Metrics - Tasks](../screenshots/settings/metrics-tasks.png) -![Server Settings](./screenshots/30-settings-server.png) +![Metrics - Plugins Overview](../screenshots/settings/metrics-plugins-overview.png) -#### Custom Metadata Templates +![Metrics - Plugins Expanded](../screenshots/settings/metrics-plugins-expanded.png) -Create templates for displaying custom metadata. +#### Plugins -![Custom Metadata](./screenshots/30-settings-server-custom-metadata.png) +Manage metadata plugins for enhanced library information. -![Custom Metadata Templates](./screenshots/30-settings-server-custom-metadata-templates.png) +![Plugin Settings - Empty](../screenshots/plugins/settings-plugins.png) -### User Management +##### Creating a Plugin -Manage users and their permissions. +Configure plugins with general settings, credentials, permissions, and execution options. -![User Management](./screenshots/33-settings-users.png) +![Create Plugin - General](../screenshots/plugins/create-general.png) -### Sharing Tags +![Create Plugin - Execution](../screenshots/plugins/create-execution.png) -Configure sharing tags for library access control. +![Create Plugin - Permissions](../screenshots/plugins/create-permissions.png) -![Sharing Tags](./screenshots/34-settings-sharing-tags.png) +![Create Plugin - Credentials](../screenshots/plugins/create-credentials.png) -### Tasks & Background Jobs +![Plugin Settings - With Echo Plugin](../screenshots/plugins/settings-plugins-with-echo.png) -Monitor and manage background tasks like scanning and thumbnail generation. +##### Using Plugins + +Access plugin actions from the library sidebar or series detail pages. + +If the plugin is enabled and has the library scope, you will see the plugin dropdown in the library sidebar. + +![Library Sidebar - Plugin Dropdown](../screenshots/plugins/library-sidebar-plugin-dropdown.png) + +If the plugin is enabled and has the series scope, you will see the plugin dropdown in the series detail page. + +![Series Detail - Plugin Dropdown](../screenshots/plugins/series-detail-plugin-dropdown.png) + +##### Plugin Results + +View results after running plugins on your library content. + +![Plugin Search Results](../screenshots/plugins/search-results.png) + +![Metadata Preview](../screenshots/plugins/metadata-preview.png) + +![Apply Success](../screenshots/plugins/apply-success.png) -![Task Queue](./screenshots/31-settings-tasks.png) + -### Duplicate Detection +![Series Detail - After Plugin](../screenshots/plugins/series-detail-after-plugin.png) + +### Access + +#### Users + +Manage users and their permissions. + +![User Management](../screenshots/settings/users.png) + +#### Sharing Tags + +Configure sharing tags for library access control. + +![Sharing Tags](../screenshots/settings/sharing-tags.png) + +### Library Health + +#### Duplicates Find and manage duplicate files across your libraries. -![Duplicate Detection](./screenshots/35-settings-duplicates.png) +![Duplicate Detection](../screenshots/settings/duplicates.png) -### Book Errors +#### Book Errors View and manage books with parsing or processing errors. -![Book Errors](./screenshots/36-settings-book-errors.png) +![Book Errors](../screenshots/settings/book-errors.png) -### Cleanup +### Storage -Clean up orphaned files and database entries. +#### Thumbnails -![Cleanup Settings](./screenshots/37-settings-cleanup.png) +Clean up orphaned thumbnail files and database entries. -### PDF Cache +![Cleanup Settings](../screenshots/settings/cleanup.png) + +#### Page Cache Manage the PDF rendering cache. -![PDF Cache](./screenshots/38-settings-pdf-cache.png) +![PDF Cache](../screenshots/settings/pdf-cache.png) + +### Account -### Metrics & Monitoring +#### Profile -#### Inventory Metrics +Manage your account details and preferences. -View statistics about your library contents. +![Profile - Account](../screenshots/settings/profile.png) -![Metrics - Inventory](./screenshots/32-settings-metrics.png) +![Profile - Preferences](../screenshots/settings/profile-preferences.png) -#### Task Metrics +#### API Keys -Monitor task execution and performance. +Generate and manage API keys for integrations. -![Metrics - Tasks](./screenshots/32-settings-metrics-tasks.png) +![Profile - API Keys](../screenshots/settings/profile-api-keys.png) ## Authentication @@ -216,23 +258,23 @@ Monitor task execution and performance. Secure authentication for your library. -![Login](./screenshots/52-login-page.png) +![Login](../screenshots/navigation/login-page.png) ### Setup Wizard First-time setup for creating your admin account. -![Setup Wizard - Step 1 Empty](./screenshots/01-setup-wizard-step1-empty.png) +![Setup Wizard - Step 1 Empty](../screenshots/setup/wizard-step1-empty.png) -![Setup Wizard - Step 1 Filled](./screenshots/02-setup-wizard-step1-filled.png) +![Setup Wizard - Step 1 Filled](../screenshots/setup/wizard-step1-filled.png) -![Setup Wizard - Step 2 Skip](./screenshots/03-setup-wizard-step2-skip.png) +![Setup Wizard - Step 2 Skip](../screenshots/setup/wizard-step2-skip.png) -![Setup Wizard - Step 2 Basic Settings](./screenshots/04-setup-wizard-step2-basic-settings.png) +![Setup Wizard - Step 2 Basic Settings](../screenshots/setup/wizard-step2-basic-settings.png) -![Setup Wizard - Step 2 Advanced Settings](./screenshots/05-setup-wizard-step2-advanced-settings.png) +![Setup Wizard - Step 2 Advanced Settings](../screenshots/setup/wizard-step2-advanced-settings.png) -![Setup Complete - Dashboard](./screenshots/06-setup-complete-dashboard.png) +![Setup Complete - Dashboard](../screenshots/setup/complete-dashboard.png) ## Navigation @@ -240,10 +282,12 @@ First-time setup for creating your admin account. The sidebar provides quick access to settings and navigation. -![Sidebar - Settings Expanded](./screenshots/51-sidebar-settings-expanded.png) +![Sidebar - Settings Expanded](../screenshots/navigation/sidebar-settings-expanded.png) ### Search Search across your entire library. -![Search Results](./screenshots/53-search-results.png) +![Search Dropdown](../screenshots/navigation/search-dropdown.png) + +![Search Results](../screenshots/navigation/search-results.png) diff --git a/docs/docs/troubleshooting.md b/docs/docs/troubleshooting.md index 6ea914f7..275c1e1b 100644 --- a/docs/docs/troubleshooting.md +++ b/docs/docs/troubleshooting.md @@ -321,7 +321,7 @@ This was fixed in recent versions. If you experience this: 2. **Monitor the Task Queue**: Go to **Settings** > **Tasks** to view active and pending tasks. - ![Task Queue](./screenshots/31-settings-tasks.png) + ![Task Queue](../screenshots/settings/tasks.png) 3. **Use normal scan** for updates: Deep scans re-process everything. @@ -590,6 +590,181 @@ This was fixed in recent versions. If you experience this: 3. **Regenerate thumbnails**: Run a scan to regenerate missing thumbnails. +## Plugin Issues + +Codex supports external plugins for metadata providers and other integrations. Plugins run as separate processes managed by Codex. + +### Plugin Won't Start + +#### Symptoms + +- "INIT_ERROR" health status +- Plugin shows as disabled +- "Command not found" error + +#### Solutions + +1. **Verify the command exists**: + ```bash + # Test the command directly + /path/to/plugin/command --version + + # For Node.js plugins + node /path/to/plugin/index.js + ``` + +2. **Check working directory**: + If the plugin expects to be run from a specific directory: + ```yaml + # In plugin configuration + workingDirectory: /opt/codex/plugins/my-plugin + ``` + +3. **Verify arguments**: + Plugin arguments must be valid. Test manually: + ```bash + node /path/to/plugin/index.js arg1 arg2 + ``` + +4. **Check Docker permissions**: + If running in Docker, ensure the plugin command is available inside the container. + +### Plugin Auto-Disabled + +#### Symptoms + +- Plugin health shows "disabled" +- "disabled_reason" shows failure count exceeded threshold +- Red status badge on plugin row + +#### Recovery Steps + +1. **Go to Settings > Plugins** +2. **Review the failure history**: + - Click the expand arrow on the plugin row + - Check the "Failure History" section + - Look at error codes and messages + +3. **Fix the underlying issue**: + - `TIMEOUT`: Plugin took too long; check network or increase timeout + - `RATE_LIMITED`: External API throttling; reduce request rate + - `AUTH_FAILED`: Invalid credentials; update API key + - `RPC_ERROR`: Plugin returned an error; check plugin logs + - `PROCESS_CRASHED`: Plugin crashed; check command/args + +4. **Reset failures**: + - Click the "Reset Failures" button (refresh icon) + - This clears the failure counter + +5. **Re-enable the plugin**: + - Toggle the "Status" switch to ON + - Codex will attempt to start the plugin + +### Rate Limit Errors + +#### Symptoms + +- "RATE_LIMITED" error code +- Requests rejected before reaching plugin +- Plugin health showing "degraded" + +#### Solutions + +1. **Reduce request rate** in plugin settings: + ```yaml + rateLimitRequestsPerMinute: 30 # Lower than default 60 + ``` + +2. **Check external API limits**: + External providers (AniList, MangaUpdates, etc.) have their own rate limits. + Configure Codex's rate limit to stay well below external limits. + +3. **Stagger bulk operations**: + For bulk metadata operations, consider processing in smaller batches. + +### Invalid Credentials + +#### Symptoms + +- "AUTH_FAILED" error +- Plugin can't access external API +- 401/403 errors in failure history + +#### Solutions + +1. **Update credentials**: + - Go to Settings > Plugins + - Click Edit on the plugin + - Go to "Credentials" tab + - Enter new credentials as JSON + +2. **Check credential format**: + ```json + { + "api_key": "your-actual-api-key" + } + ``` + +3. **Verify credential delivery**: + Plugins can receive credentials via: + - `env`: Environment variables (most common) + - `config`: In the config JSON + - `stdin`: Via standard input + +### Plugin Timeout + +#### Symptoms + +- "TIMEOUT" error code +- Operations taking too long +- High latency in health checks + +#### Solutions + +1. **Check plugin health**: + - Is the external API slow? + - Is there network latency? + +2. **Increase timeout** (if using custom plugin): + Default timeout is 30 seconds. For slow APIs, consider: + - Implementing caching in the plugin + - Processing requests asynchronously + +3. **Check network connectivity**: + ```bash + # From Docker container + docker compose exec codex curl -I https://api.example.com + ``` + +### Viewing Plugin Logs + +For detailed debugging: + +1. **Check Codex logs** for plugin-related messages: + ```bash + docker compose logs | grep -i "plugin" + ``` + +2. **Check plugin process output**: + Plugin stdout/stderr may be captured in logs. + +3. **Enable debug logging**: + ```bash + CODEX_LOGGING_LEVEL=debug docker compose up + ``` + +### Common Error Codes + +| Error Code | Description | Common Fixes | +|------------|-------------|--------------| +| `INIT_ERROR` | Plugin failed to start | Check command, args, working directory | +| `TIMEOUT` | Request exceeded time limit | Check network, reduce payload size | +| `RATE_LIMITED` | Too many requests | Reduce rate limit setting | +| `AUTH_FAILED` | Authentication failed | Update credentials | +| `RPC_ERROR` | Plugin returned an error | Check plugin logs, fix plugin code | +| `PROCESS_CRASHED` | Plugin process died | Check command, memory, dependencies | +| `PROTOCOL_ERROR` | Invalid JSON-RPC response | Plugin SDK mismatch, update plugin | + ## Getting Help If you're still experiencing issues: @@ -598,9 +773,9 @@ If you're still experiencing issues: Go to **Settings** > **Metrics** to view server statistics including inventory counts and task history. -![Server Metrics - Inventory](./screenshots/32-settings-metrics.png) +![Server Metrics - Inventory](../screenshots/settings/metrics.png) -![Server Metrics - Tasks](./screenshots/32-settings-metrics-tasks.png) +![Server Metrics - Tasks](../screenshots/settings/metrics-tasks.png) ### Gather Information diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index a1c859cc..f795a3ef 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -1,7 +1,11 @@ -import {themes as prismThemes} from 'prism-react-renderer'; -import type {Config} from '@docusaurus/types'; +import type { Config } from '@docusaurus/types'; import type * as Preset from '@docusaurus/preset-classic'; import type * as OpenApiPlugin from 'docusaurus-plugin-openapi-docs'; +import { themes as prismThemes } from 'prism-react-renderer'; + +// Read version from package.json +import packageJson from './package.json'; +const appVersion = packageJson.version; // This runs in Node.js - Don't use client-side code here (browser APIs, JSX...) @@ -55,6 +59,16 @@ const config: Config = { ], plugins: [ + [ + '@docusaurus/plugin-content-docs', + { + id: 'dev', + path: 'dev', + routeBasePath: 'dev', + sidebarPath: './devSidebar.ts', + editUrl: 'https://github.com/AshDevFr/codex/edit/main/docs/', + }, + ], [ 'docusaurus-plugin-openapi-docs', { @@ -86,6 +100,13 @@ const config: Config = { language: ["en"], + // Index both main docs and dev docs + docsDir: ['docs', 'dev'], + docsRouteBasePath: ['docs', 'dev'], + + // Exclude API docs from search indexing + ignoreFiles: [/docs\/api\/.*/], + // Customize the keyboard shortcut to focus search bar (default is "mod+k"): // searchBarShortcutKeymap: "s", // Use 'S' key // searchBarShortcutKeymap: "ctrl+shift+f", // Use Ctrl+Shift+F @@ -109,12 +130,22 @@ const config: Config = { src: 'img/codex-logo-color.svg', }, items: [ + { + type: 'html', + position: 'left', + value: `v${appVersion}`, + }, { type: 'docSidebar', sidebarId: 'tutorialSidebar', position: 'left', label: 'Docs', }, + { + to: '/dev/intro', + position: 'left', + label: 'Dev', + }, { to: '/docs/api/codex-api', position: 'left', @@ -147,6 +178,19 @@ const config: Config = { }, ], }, + { + title: 'Developers', + items: [ + { + label: 'Plugin Development', + to: '/dev/plugins/overview', + }, + { + label: 'Contributing', + to: '/dev/contributing/development', + }, + ], + }, { title: 'Project', items: [ diff --git a/docs/package.json b/docs/package.json index 6ed0129b..5bac5f61 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,6 @@ { "name": "docs", - "version": "0.0.0", + "version": "1.0.1", "private": true, "scripts": { "docusaurus": "docusaurus", diff --git a/docs/docs/screenshots/09-add-library-formats-books.png b/docs/screenshots/libraries/add-library-formats-books.png similarity index 75% rename from docs/docs/screenshots/09-add-library-formats-books.png rename to docs/screenshots/libraries/add-library-formats-books.png index 03b609de..137c17bd 100644 Binary files a/docs/docs/screenshots/09-add-library-formats-books.png and b/docs/screenshots/libraries/add-library-formats-books.png differ diff --git a/docs/docs/screenshots/09-add-library-formats-comics.png b/docs/screenshots/libraries/add-library-formats-comics.png similarity index 68% rename from docs/docs/screenshots/09-add-library-formats-comics.png rename to docs/screenshots/libraries/add-library-formats-comics.png index 81651bf6..21c7696a 100644 Binary files a/docs/docs/screenshots/09-add-library-formats-comics.png and b/docs/screenshots/libraries/add-library-formats-comics.png differ diff --git a/docs/docs/screenshots/09-add-library-formats-manga.png b/docs/screenshots/libraries/add-library-formats-manga.png similarity index 94% rename from docs/docs/screenshots/09-add-library-formats-manga.png rename to docs/screenshots/libraries/add-library-formats-manga.png index f3b59bda..ae695162 100644 Binary files a/docs/docs/screenshots/09-add-library-formats-manga.png and b/docs/screenshots/libraries/add-library-formats-manga.png differ diff --git a/docs/screenshots/libraries/add-library-general-books.png b/docs/screenshots/libraries/add-library-general-books.png new file mode 100644 index 00000000..b6f76654 Binary files /dev/null and b/docs/screenshots/libraries/add-library-general-books.png differ diff --git a/docs/docs/screenshots/07-add-library-general-comics.png b/docs/screenshots/libraries/add-library-general-comics.png similarity index 77% rename from docs/docs/screenshots/07-add-library-general-comics.png rename to docs/screenshots/libraries/add-library-general-comics.png index 0f4eaba3..94529d86 100644 Binary files a/docs/docs/screenshots/07-add-library-general-comics.png and b/docs/screenshots/libraries/add-library-general-comics.png differ diff --git a/docs/docs/screenshots/07-add-library-general-manga.png b/docs/screenshots/libraries/add-library-general-manga.png similarity index 65% rename from docs/docs/screenshots/07-add-library-general-manga.png rename to docs/screenshots/libraries/add-library-general-manga.png index ea8f8511..b98a457b 100644 Binary files a/docs/docs/screenshots/07-add-library-general-manga.png and b/docs/screenshots/libraries/add-library-general-manga.png differ diff --git a/docs/docs/screenshots/10-add-library-scanning-books.png b/docs/screenshots/libraries/add-library-scanning-books.png similarity index 84% rename from docs/docs/screenshots/10-add-library-scanning-books.png rename to docs/screenshots/libraries/add-library-scanning-books.png index a760cfbc..91ba7df8 100644 Binary files a/docs/docs/screenshots/10-add-library-scanning-books.png and b/docs/screenshots/libraries/add-library-scanning-books.png differ diff --git a/docs/docs/screenshots/10-add-library-scanning-comics.png b/docs/screenshots/libraries/add-library-scanning-comics.png similarity index 59% rename from docs/docs/screenshots/10-add-library-scanning-comics.png rename to docs/screenshots/libraries/add-library-scanning-comics.png index 348062c7..725f9f48 100644 Binary files a/docs/docs/screenshots/10-add-library-scanning-comics.png and b/docs/screenshots/libraries/add-library-scanning-comics.png differ diff --git a/docs/screenshots/libraries/add-library-scanning-manga.png b/docs/screenshots/libraries/add-library-scanning-manga.png new file mode 100644 index 00000000..95bfc4f0 Binary files /dev/null and b/docs/screenshots/libraries/add-library-scanning-manga.png differ diff --git a/docs/docs/screenshots/08-add-library-strategy-books.png b/docs/screenshots/libraries/add-library-strategy-books.png similarity index 89% rename from docs/docs/screenshots/08-add-library-strategy-books.png rename to docs/screenshots/libraries/add-library-strategy-books.png index a6160bee..394de7dd 100644 Binary files a/docs/docs/screenshots/08-add-library-strategy-books.png and b/docs/screenshots/libraries/add-library-strategy-books.png differ diff --git a/docs/docs/screenshots/08-add-library-strategy-comics.png b/docs/screenshots/libraries/add-library-strategy-comics.png similarity index 98% rename from docs/docs/screenshots/08-add-library-strategy-comics.png rename to docs/screenshots/libraries/add-library-strategy-comics.png index a4f59f59..e5c23cb2 100644 Binary files a/docs/docs/screenshots/08-add-library-strategy-comics.png and b/docs/screenshots/libraries/add-library-strategy-comics.png differ diff --git a/docs/docs/screenshots/08-add-library-strategy-manga.png b/docs/screenshots/libraries/add-library-strategy-manga.png similarity index 79% rename from docs/docs/screenshots/08-add-library-strategy-manga.png rename to docs/screenshots/libraries/add-library-strategy-manga.png index 70ef5e02..f7dedad7 100644 Binary files a/docs/docs/screenshots/08-add-library-strategy-manga.png and b/docs/screenshots/libraries/add-library-strategy-manga.png differ diff --git a/docs/docs/screenshots/12-all-libraries-books.png b/docs/screenshots/libraries/all-libraries-books.png similarity index 53% rename from docs/docs/screenshots/12-all-libraries-books.png rename to docs/screenshots/libraries/all-libraries-books.png index 3790eea8..3e113808 100644 Binary files a/docs/docs/screenshots/12-all-libraries-books.png and b/docs/screenshots/libraries/all-libraries-books.png differ diff --git a/docs/screenshots/libraries/all-libraries-series.png b/docs/screenshots/libraries/all-libraries-series.png new file mode 100644 index 00000000..8d734200 Binary files /dev/null and b/docs/screenshots/libraries/all-libraries-series.png differ diff --git a/docs/docs/screenshots/15-book-detail.png b/docs/screenshots/libraries/book-detail.png similarity index 82% rename from docs/docs/screenshots/15-book-detail.png rename to docs/screenshots/libraries/book-detail.png index d9e28b34..4cd67caa 100644 Binary files a/docs/docs/screenshots/15-book-detail.png and b/docs/screenshots/libraries/book-detail.png differ diff --git a/docs/screenshots/libraries/home-with-libraries.png b/docs/screenshots/libraries/home-with-libraries.png new file mode 100644 index 00000000..b65bbb8d Binary files /dev/null and b/docs/screenshots/libraries/home-with-libraries.png differ diff --git a/docs/screenshots/libraries/library-detail-series.png b/docs/screenshots/libraries/library-detail-series.png new file mode 100644 index 00000000..5a48fbc2 Binary files /dev/null and b/docs/screenshots/libraries/library-detail-series.png differ diff --git a/docs/docs/screenshots/14-series-detail.png b/docs/screenshots/libraries/series-detail.png similarity index 92% rename from docs/docs/screenshots/14-series-detail.png rename to docs/screenshots/libraries/series-detail.png index 8dfa1cbe..cb32dbd5 100644 Binary files a/docs/docs/screenshots/14-series-detail.png and b/docs/screenshots/libraries/series-detail.png differ diff --git a/docs/screenshots/navigation/home-dashboard.png b/docs/screenshots/navigation/home-dashboard.png new file mode 100644 index 00000000..754173d7 Binary files /dev/null and b/docs/screenshots/navigation/home-dashboard.png differ diff --git a/docs/docs/screenshots/52-login-page.png b/docs/screenshots/navigation/login-page.png similarity index 100% rename from docs/docs/screenshots/52-login-page.png rename to docs/screenshots/navigation/login-page.png diff --git a/docs/screenshots/navigation/search-dropdown.png b/docs/screenshots/navigation/search-dropdown.png new file mode 100644 index 00000000..53f0e195 Binary files /dev/null and b/docs/screenshots/navigation/search-dropdown.png differ diff --git a/docs/screenshots/navigation/search-results.png b/docs/screenshots/navigation/search-results.png new file mode 100644 index 00000000..e68ac90b Binary files /dev/null and b/docs/screenshots/navigation/search-results.png differ diff --git a/docs/screenshots/navigation/sidebar-settings-expanded.png b/docs/screenshots/navigation/sidebar-settings-expanded.png new file mode 100644 index 00000000..bd2dff10 Binary files /dev/null and b/docs/screenshots/navigation/sidebar-settings-expanded.png differ diff --git a/docs/screenshots/plugins/apply-success.png b/docs/screenshots/plugins/apply-success.png new file mode 100644 index 00000000..7bbeb461 Binary files /dev/null and b/docs/screenshots/plugins/apply-success.png differ diff --git a/docs/screenshots/plugins/create-credentials.png b/docs/screenshots/plugins/create-credentials.png new file mode 100644 index 00000000..024839c4 Binary files /dev/null and b/docs/screenshots/plugins/create-credentials.png differ diff --git a/docs/screenshots/plugins/create-execution.png b/docs/screenshots/plugins/create-execution.png new file mode 100644 index 00000000..a141410a Binary files /dev/null and b/docs/screenshots/plugins/create-execution.png differ diff --git a/docs/screenshots/plugins/create-general.png b/docs/screenshots/plugins/create-general.png new file mode 100644 index 00000000..79ca8811 Binary files /dev/null and b/docs/screenshots/plugins/create-general.png differ diff --git a/docs/screenshots/plugins/create-permissions.png b/docs/screenshots/plugins/create-permissions.png new file mode 100644 index 00000000..2eb32eda Binary files /dev/null and b/docs/screenshots/plugins/create-permissions.png differ diff --git a/docs/screenshots/plugins/library-auto-match-success.png b/docs/screenshots/plugins/library-auto-match-success.png new file mode 100644 index 00000000..f9dc3443 Binary files /dev/null and b/docs/screenshots/plugins/library-auto-match-success.png differ diff --git a/docs/screenshots/plugins/library-sidebar-plugin-dropdown.png b/docs/screenshots/plugins/library-sidebar-plugin-dropdown.png new file mode 100644 index 00000000..a66b14c6 Binary files /dev/null and b/docs/screenshots/plugins/library-sidebar-plugin-dropdown.png differ diff --git a/docs/screenshots/plugins/metadata-preview.png b/docs/screenshots/plugins/metadata-preview.png new file mode 100644 index 00000000..3ba516da Binary files /dev/null and b/docs/screenshots/plugins/metadata-preview.png differ diff --git a/docs/screenshots/plugins/search-results.png b/docs/screenshots/plugins/search-results.png new file mode 100644 index 00000000..f8a0c885 Binary files /dev/null and b/docs/screenshots/plugins/search-results.png differ diff --git a/docs/screenshots/plugins/series-detail-after-plugin.png b/docs/screenshots/plugins/series-detail-after-plugin.png new file mode 100644 index 00000000..52ba9daf Binary files /dev/null and b/docs/screenshots/plugins/series-detail-after-plugin.png differ diff --git a/docs/screenshots/plugins/series-detail-plugin-dropdown.png b/docs/screenshots/plugins/series-detail-plugin-dropdown.png new file mode 100644 index 00000000..21dc657d Binary files /dev/null and b/docs/screenshots/plugins/series-detail-plugin-dropdown.png differ diff --git a/docs/screenshots/plugins/settings-plugins-with-echo.png b/docs/screenshots/plugins/settings-plugins-with-echo.png new file mode 100644 index 00000000..5b552651 Binary files /dev/null and b/docs/screenshots/plugins/settings-plugins-with-echo.png differ diff --git a/docs/screenshots/plugins/settings-plugins.png b/docs/screenshots/plugins/settings-plugins.png new file mode 100644 index 00000000..98e6ead3 Binary files /dev/null and b/docs/screenshots/plugins/settings-plugins.png differ diff --git a/docs/docs/screenshots/20-reader-comic-settings.png b/docs/screenshots/reader/comic-settings.png similarity index 100% rename from docs/docs/screenshots/20-reader-comic-settings.png rename to docs/screenshots/reader/comic-settings.png diff --git a/docs/docs/screenshots/20-reader-comic-toolbar.png b/docs/screenshots/reader/comic-toolbar.png similarity index 100% rename from docs/docs/screenshots/20-reader-comic-toolbar.png rename to docs/screenshots/reader/comic-toolbar.png diff --git a/docs/docs/screenshots/20-reader-comic-view.png b/docs/screenshots/reader/comic-view.png similarity index 100% rename from docs/docs/screenshots/20-reader-comic-view.png rename to docs/screenshots/reader/comic-view.png diff --git a/docs/docs/screenshots/21-reader-epub-settings.png b/docs/screenshots/reader/epub-settings.png similarity index 100% rename from docs/docs/screenshots/21-reader-epub-settings.png rename to docs/screenshots/reader/epub-settings.png diff --git a/docs/docs/screenshots/21-reader-epub-toolbar.png b/docs/screenshots/reader/epub-toolbar.png similarity index 100% rename from docs/docs/screenshots/21-reader-epub-toolbar.png rename to docs/screenshots/reader/epub-toolbar.png diff --git a/docs/docs/screenshots/21-reader-epub-view.png b/docs/screenshots/reader/epub-view.png similarity index 100% rename from docs/docs/screenshots/21-reader-epub-view.png rename to docs/screenshots/reader/epub-view.png diff --git a/docs/docs/screenshots/22-reader-pdf-settings.png b/docs/screenshots/reader/pdf-settings.png similarity index 100% rename from docs/docs/screenshots/22-reader-pdf-settings.png rename to docs/screenshots/reader/pdf-settings.png diff --git a/docs/docs/screenshots/22-reader-pdf-toolbar.png b/docs/screenshots/reader/pdf-toolbar.png similarity index 100% rename from docs/docs/screenshots/22-reader-pdf-toolbar.png rename to docs/screenshots/reader/pdf-toolbar.png diff --git a/docs/docs/screenshots/22-reader-pdf-view.png b/docs/screenshots/reader/pdf-view.png similarity index 100% rename from docs/docs/screenshots/22-reader-pdf-view.png rename to docs/screenshots/reader/pdf-view.png diff --git a/docs/docs/screenshots/36-settings-book-errors.png b/docs/screenshots/settings/book-errors.png similarity index 56% rename from docs/docs/screenshots/36-settings-book-errors.png rename to docs/screenshots/settings/book-errors.png index 24726571..04cb6c04 100644 Binary files a/docs/docs/screenshots/36-settings-book-errors.png and b/docs/screenshots/settings/book-errors.png differ diff --git a/docs/screenshots/settings/cleanup.png b/docs/screenshots/settings/cleanup.png new file mode 100644 index 00000000..dc279099 Binary files /dev/null and b/docs/screenshots/settings/cleanup.png differ diff --git a/docs/docs/screenshots/35-settings-duplicates.png b/docs/screenshots/settings/duplicates.png similarity index 58% rename from docs/docs/screenshots/35-settings-duplicates.png rename to docs/screenshots/settings/duplicates.png index 007b5dac..98d57f42 100644 Binary files a/docs/docs/screenshots/35-settings-duplicates.png and b/docs/screenshots/settings/duplicates.png differ diff --git a/docs/screenshots/settings/metrics-plugins-expanded.png b/docs/screenshots/settings/metrics-plugins-expanded.png new file mode 100644 index 00000000..a177e3ce Binary files /dev/null and b/docs/screenshots/settings/metrics-plugins-expanded.png differ diff --git a/docs/screenshots/settings/metrics-plugins-overview.png b/docs/screenshots/settings/metrics-plugins-overview.png new file mode 100644 index 00000000..aace407b Binary files /dev/null and b/docs/screenshots/settings/metrics-plugins-overview.png differ diff --git a/docs/screenshots/settings/metrics-tasks.png b/docs/screenshots/settings/metrics-tasks.png new file mode 100644 index 00000000..6a85d71e Binary files /dev/null and b/docs/screenshots/settings/metrics-tasks.png differ diff --git a/docs/screenshots/settings/metrics.png b/docs/screenshots/settings/metrics.png new file mode 100644 index 00000000..b5bfc849 Binary files /dev/null and b/docs/screenshots/settings/metrics.png differ diff --git a/docs/screenshots/settings/pdf-cache.png b/docs/screenshots/settings/pdf-cache.png new file mode 100644 index 00000000..287635ea Binary files /dev/null and b/docs/screenshots/settings/pdf-cache.png differ diff --git a/docs/docs/screenshots/40-settings-profile-api-keys.png b/docs/screenshots/settings/profile-api-keys.png similarity index 72% rename from docs/docs/screenshots/40-settings-profile-api-keys.png rename to docs/screenshots/settings/profile-api-keys.png index 4f0ed43c..64217e86 100644 Binary files a/docs/docs/screenshots/40-settings-profile-api-keys.png and b/docs/screenshots/settings/profile-api-keys.png differ diff --git a/docs/docs/screenshots/41-settings-profile-preferences.png b/docs/screenshots/settings/profile-preferences.png similarity index 68% rename from docs/docs/screenshots/41-settings-profile-preferences.png rename to docs/screenshots/settings/profile-preferences.png index 499d8b1b..20d51df6 100644 Binary files a/docs/docs/screenshots/41-settings-profile-preferences.png and b/docs/screenshots/settings/profile-preferences.png differ diff --git a/docs/docs/screenshots/39-settings-profile.png b/docs/screenshots/settings/profile.png similarity index 55% rename from docs/docs/screenshots/39-settings-profile.png rename to docs/screenshots/settings/profile.png index 4bf896e4..3ff6ad84 100644 Binary files a/docs/docs/screenshots/39-settings-profile.png and b/docs/screenshots/settings/profile.png differ diff --git a/docs/screenshots/settings/server-custom-metadata-templates.png b/docs/screenshots/settings/server-custom-metadata-templates.png new file mode 100644 index 00000000..d5d6e390 Binary files /dev/null and b/docs/screenshots/settings/server-custom-metadata-templates.png differ diff --git a/docs/screenshots/settings/server-custom-metadata.png b/docs/screenshots/settings/server-custom-metadata.png new file mode 100644 index 00000000..b63d547a Binary files /dev/null and b/docs/screenshots/settings/server-custom-metadata.png differ diff --git a/docs/screenshots/settings/server.png b/docs/screenshots/settings/server.png new file mode 100644 index 00000000..0d0ffc28 Binary files /dev/null and b/docs/screenshots/settings/server.png differ diff --git a/docs/docs/screenshots/34-settings-sharing-tags.png b/docs/screenshots/settings/sharing-tags.png similarity index 71% rename from docs/docs/screenshots/34-settings-sharing-tags.png rename to docs/screenshots/settings/sharing-tags.png index 1e7ce298..91c5bcd0 100644 Binary files a/docs/docs/screenshots/34-settings-sharing-tags.png and b/docs/screenshots/settings/sharing-tags.png differ diff --git a/docs/screenshots/settings/tasks.png b/docs/screenshots/settings/tasks.png new file mode 100644 index 00000000..453691d6 Binary files /dev/null and b/docs/screenshots/settings/tasks.png differ diff --git a/docs/screenshots/settings/users.png b/docs/screenshots/settings/users.png new file mode 100644 index 00000000..0fc4e167 Binary files /dev/null and b/docs/screenshots/settings/users.png differ diff --git a/docs/screenshots/setup/complete-dashboard.png b/docs/screenshots/setup/complete-dashboard.png new file mode 100644 index 00000000..31ccffe5 Binary files /dev/null and b/docs/screenshots/setup/complete-dashboard.png differ diff --git a/docs/docs/screenshots/01-setup-wizard-step1-empty.png b/docs/screenshots/setup/wizard-step1-empty.png similarity index 100% rename from docs/docs/screenshots/01-setup-wizard-step1-empty.png rename to docs/screenshots/setup/wizard-step1-empty.png diff --git a/docs/docs/screenshots/02-setup-wizard-step1-filled.png b/docs/screenshots/setup/wizard-step1-filled.png similarity index 100% rename from docs/docs/screenshots/02-setup-wizard-step1-filled.png rename to docs/screenshots/setup/wizard-step1-filled.png diff --git a/docs/docs/screenshots/05-setup-wizard-step2-advanced-settings.png b/docs/screenshots/setup/wizard-step2-advanced-settings.png similarity index 100% rename from docs/docs/screenshots/05-setup-wizard-step2-advanced-settings.png rename to docs/screenshots/setup/wizard-step2-advanced-settings.png diff --git a/docs/docs/screenshots/04-setup-wizard-step2-basic-settings.png b/docs/screenshots/setup/wizard-step2-basic-settings.png similarity index 100% rename from docs/docs/screenshots/04-setup-wizard-step2-basic-settings.png rename to docs/screenshots/setup/wizard-step2-basic-settings.png diff --git a/docs/docs/screenshots/03-setup-wizard-step2-skip.png b/docs/screenshots/setup/wizard-step2-skip.png similarity index 100% rename from docs/docs/screenshots/03-setup-wizard-step2-skip.png rename to docs/screenshots/setup/wizard-step2-skip.png diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 6d12344e..3edb2208 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -60,15 +60,6 @@ const sidebars: SidebarsConfig = { ], }, "troubleshooting", - { - type: "category", - label: "Development", - items: [ - "development/development", - "development/architecture", - "development/migrations", - ], - }, ], apiSidebar, }; diff --git a/docs/tsconfig.json b/docs/tsconfig.json index 920d7a65..a1a8843f 100644 --- a/docs/tsconfig.json +++ b/docs/tsconfig.json @@ -2,7 +2,8 @@ // This file is not used in compilation. It is here just for a nice editor experience. "extends": "@docusaurus/tsconfig", "compilerOptions": { - "baseUrl": "." + "baseUrl": ".", + "resolveJsonModule": true }, "exclude": [".docusaurus", "build"] } diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 6e03aa4b..610b9ef1 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -58,6 +58,16 @@ mod m20260122_000033_seed_pdf_cache_settings; // Add analysis_errors column to books table mod m20260123_000034_add_analysis_errors_column; +// Plugin system +mod m20260127_000035_create_plugins; +mod m20260129_000036_seed_plugin_settings; + +// Thumbnail cron settings +mod m20260130_000037_seed_thumbnail_cron_settings; + +// Update validation_rules for settings UI hints +mod m20260130_000038_update_settings_validation_rules; + pub struct Migrator; #[async_trait::async_trait] @@ -109,6 +119,13 @@ impl MigratorTrait for Migrator { Box::new(m20260122_000033_seed_pdf_cache_settings::Migration), // Add analysis_errors column to books table Box::new(m20260123_000034_add_analysis_errors_column::Migration), + // Plugin system + Box::new(m20260127_000035_create_plugins::Migration), + Box::new(m20260129_000036_seed_plugin_settings::Migration), + // Thumbnail cron settings + Box::new(m20260130_000037_seed_thumbnail_cron_settings::Migration), + // Update validation_rules for settings UI hints + Box::new(m20260130_000038_update_settings_validation_rules::Migration), ] } } diff --git a/migration/src/m20260127_000035_create_plugins.rs b/migration/src/m20260127_000035_create_plugins.rs new file mode 100644 index 00000000..22f4048c --- /dev/null +++ b/migration/src/m20260127_000035_create_plugins.rs @@ -0,0 +1,431 @@ +//! Create plugins table for external plugin processes +//! +//! Plugins are external processes that communicate with Codex via JSON-RPC over stdio. +//! This table stores plugin configuration, permissions, and health status. +//! +//! Plugin types: +//! - `system`: Admin-configured plugins for metadata fetching (e.g., MangaBaka) +//! - `user`: User-configured plugins for sync/recommendations (e.g., AniList sync) + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let is_postgres = manager.get_database_backend() == sea_orm::DatabaseBackend::Postgres; + + let mut table = Table::create(); + table.table(Plugins::Table).if_not_exists(); + + // ID column - different defaults for Postgres vs SQLite + if is_postgres { + table.col( + ColumnDef::new(Plugins::Id) + .uuid() + .not_null() + .primary_key() + .extra("DEFAULT gen_random_uuid()"), + ); + } else { + table.col(ColumnDef::new(Plugins::Id).uuid().not_null().primary_key()); + } + + manager + .create_table( + table + // Identity + .col( + ColumnDef::new(Plugins::Name) + .string_len(100) + .not_null() + .unique_key(), + ) + .col( + ColumnDef::new(Plugins::DisplayName) + .string_len(255) + .not_null(), + ) + .col(ColumnDef::new(Plugins::Description).text()) + // Plugin type: 'system' (admin-configured) or 'user' (per-user instances) + .col( + ColumnDef::new(Plugins::PluginType) + .string_len(20) + .not_null() + .default("system"), + ) + // Execution + .col(ColumnDef::new(Plugins::Command).text().not_null()) + .col( + ColumnDef::new(Plugins::Args) + .json() + .not_null() + .default("[]"), + ) + .col(ColumnDef::new(Plugins::Env).json().not_null().default("{}")) + .col(ColumnDef::new(Plugins::WorkingDirectory).text()) + // Permissions (RBAC) + .col( + ColumnDef::new(Plugins::Permissions) + .json() + .not_null() + .default("[]"), + ) + // Scopes (where plugin can be invoked) + .col( + ColumnDef::new(Plugins::Scopes) + .json() + .not_null() + .default("[]"), + ) + // Library filtering (restrict plugin to specific libraries) + // Empty array = all libraries, non-empty = only these library UUIDs + .col( + ColumnDef::new(Plugins::LibraryIds) + .json() + .not_null() + .default("[]"), + ) + // Credentials (encrypted, passed as env vars or init message) + .col(ColumnDef::new(Plugins::Credentials).binary()) + .col( + ColumnDef::new(Plugins::CredentialDelivery) + .string_len(20) + .not_null() + .default("env"), + ) + // Plugin configuration + .col( + ColumnDef::new(Plugins::Config) + .json() + .not_null() + .default("{}"), + ) + // Manifest (cached from plugin after first connection) + .col(ColumnDef::new(Plugins::Manifest).json()) + // State + .col( + ColumnDef::new(Plugins::Enabled) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(Plugins::HealthStatus) + .string_len(20) + .not_null() + .default("unknown"), + ) + .col( + ColumnDef::new(Plugins::FailureCount) + .integer() + .not_null() + .default(0), + ) + .col(ColumnDef::new(Plugins::LastFailureAt).timestamp_with_time_zone()) + .col(ColumnDef::new(Plugins::LastSuccessAt).timestamp_with_time_zone()) + .col(ColumnDef::new(Plugins::DisabledReason).text()) + // Rate limiting + .col( + ColumnDef::new(Plugins::RateLimitRequestsPerMinute) + .integer() + .default(60), + ) + // Timestamps + .col({ + let mut col = ColumnDef::new(Plugins::CreatedAt); + col.timestamp_with_time_zone().not_null(); + if is_postgres { + col.extra("DEFAULT NOW()"); + } else { + col.extra("DEFAULT CURRENT_TIMESTAMP"); + } + col + }) + .col({ + let mut col = ColumnDef::new(Plugins::UpdatedAt); + col.timestamp_with_time_zone().not_null(); + if is_postgres { + col.extra("DEFAULT NOW()"); + } else { + col.extra("DEFAULT CURRENT_TIMESTAMP"); + } + col + }) + // Audit trail + .col(ColumnDef::new(Plugins::CreatedBy).uuid()) + .col(ColumnDef::new(Plugins::UpdatedBy).uuid()) + // Foreign keys (optional - allow null for system-created plugins) + .foreign_key( + ForeignKey::create() + .name("fk_plugins_created_by") + .from(Plugins::Table, Plugins::CreatedBy) + .to(Users::Table, Users::Id) + .on_delete(ForeignKeyAction::SetNull) + .on_update(ForeignKeyAction::NoAction), + ) + .foreign_key( + ForeignKey::create() + .name("fk_plugins_updated_by") + .from(Plugins::Table, Plugins::UpdatedBy) + .to(Users::Table, Users::Id) + .on_delete(ForeignKeyAction::SetNull) + .on_update(ForeignKeyAction::NoAction), + ) + .to_owned(), + ) + .await?; + + // Add CHECK constraints on enum columns to prevent data corruption + // Note: SQLite doesn't support ALTER TABLE ADD CONSTRAINT, so we use raw SQL + // PostgreSQL and SQLite both support CHECK constraints in raw SQL + let check_plugin_type = if is_postgres { + "ALTER TABLE plugins ADD CONSTRAINT chk_plugins_plugin_type CHECK (plugin_type IN ('system', 'user'))" + } else { + // SQLite requires recreating the table to add constraints, but we can add a trigger instead + // For simplicity, we skip CHECK constraints in SQLite as it has less strict enforcement anyway + "" + }; + + let check_health_status = if is_postgres { + "ALTER TABLE plugins ADD CONSTRAINT chk_plugins_health_status CHECK (health_status IN ('unknown', 'healthy', 'degraded', 'unhealthy', 'disabled'))" + } else { + "" + }; + + let check_credential_delivery = if is_postgres { + "ALTER TABLE plugins ADD CONSTRAINT chk_plugins_credential_delivery CHECK (credential_delivery IN ('env', 'stdin'))" + } else { + "" + }; + + // Execute CHECK constraints (PostgreSQL only) + if is_postgres { + let db = manager.get_connection(); + db.execute_unprepared(check_plugin_type).await?; + db.execute_unprepared(check_health_status).await?; + db.execute_unprepared(check_credential_delivery).await?; + } + + // Index on enabled for finding active plugins + manager + .create_index( + Index::create() + .name("idx_plugins_enabled") + .table(Plugins::Table) + .col(Plugins::Enabled) + .to_owned(), + ) + .await?; + + // Index on health_status for filtering by health + manager + .create_index( + Index::create() + .name("idx_plugins_health_status") + .table(Plugins::Table) + .col(Plugins::HealthStatus) + .to_owned(), + ) + .await?; + + // Index on plugin_type for filtering system vs user plugins + manager + .create_index( + Index::create() + .name("idx_plugins_plugin_type") + .table(Plugins::Table) + .col(Plugins::PluginType) + .to_owned(), + ) + .await?; + + // Create plugin_failures table for time-windowed failure tracking + let mut failures_table = Table::create(); + failures_table.table(PluginFailures::Table).if_not_exists(); + + // ID column - different defaults for Postgres vs SQLite + if is_postgres { + failures_table.col( + ColumnDef::new(PluginFailures::Id) + .uuid() + .not_null() + .primary_key() + .extra("DEFAULT gen_random_uuid()"), + ); + } else { + failures_table.col( + ColumnDef::new(PluginFailures::Id) + .uuid() + .not_null() + .primary_key(), + ); + } + + manager + .create_table( + failures_table + // Reference to plugin + .col(ColumnDef::new(PluginFailures::PluginId).uuid().not_null()) + // Failure details + .col( + ColumnDef::new(PluginFailures::ErrorMessage) + .text() + .not_null(), + ) + .col(ColumnDef::new(PluginFailures::ErrorCode).string_len(50)) + .col(ColumnDef::new(PluginFailures::Method).string_len(100)) + // Context (optional) + .col(ColumnDef::new(PluginFailures::RequestId).string_len(100)) + .col(ColumnDef::new(PluginFailures::Context).json()) + // Request summary (sanitized, sensitive fields redacted) + .col(ColumnDef::new(PluginFailures::RequestSummary).text()) + // Timestamp + .col({ + let mut col = ColumnDef::new(PluginFailures::OccurredAt); + col.timestamp_with_time_zone().not_null(); + if is_postgres { + col.extra("DEFAULT NOW()"); + } else { + col.extra("DEFAULT CURRENT_TIMESTAMP"); + } + col + }) + // TTL: Failures older than retention period are auto-deleted + // Default retention: 30 days + .col({ + let mut col = ColumnDef::new(PluginFailures::ExpiresAt); + col.timestamp_with_time_zone().not_null(); + if is_postgres { + col.extra("DEFAULT (NOW() + INTERVAL '30 days')"); + } else { + col.extra("DEFAULT (datetime('now', '+30 days'))"); + } + col + }) + // Foreign key to plugins table + .foreign_key( + ForeignKey::create() + .name("fk_plugin_failures_plugin_id") + .from(PluginFailures::Table, PluginFailures::PluginId) + .to(Plugins::Table, Plugins::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::NoAction), + ) + .to_owned(), + ) + .await?; + + // Index on plugin_id for filtering failures by plugin + manager + .create_index( + Index::create() + .name("idx_plugin_failures_plugin_id") + .table(PluginFailures::Table) + .col(PluginFailures::PluginId) + .to_owned(), + ) + .await?; + + // Index on occurred_at for time-window queries + manager + .create_index( + Index::create() + .name("idx_plugin_failures_occurred_at") + .table(PluginFailures::Table) + .col(PluginFailures::PluginId) + .col(PluginFailures::OccurredAt) + .to_owned(), + ) + .await?; + + // Index on expires_at for cleanup job + manager + .create_index( + Index::create() + .name("idx_plugin_failures_expires_at") + .table(PluginFailures::Table) + .col(PluginFailures::ExpiresAt) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Drop plugin_failures first (has FK to plugins) + manager + .drop_table(Table::drop().table(PluginFailures::Table).to_owned()) + .await?; + + manager + .drop_table(Table::drop().table(Plugins::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Plugins { + Table, + Id, + // Identity + Name, + DisplayName, + Description, + PluginType, + // Execution + Command, + Args, + Env, + WorkingDirectory, + // Permissions & Scopes + Permissions, + Scopes, + // Library filtering + LibraryIds, + // Credentials + Credentials, + CredentialDelivery, + // Configuration + Config, + Manifest, + // State + Enabled, + HealthStatus, + FailureCount, + LastFailureAt, + LastSuccessAt, + DisabledReason, + // Rate limiting + RateLimitRequestsPerMinute, + // Timestamps + CreatedAt, + UpdatedAt, + // Audit trail + CreatedBy, + UpdatedBy, +} + +#[derive(DeriveIden)] +enum Users { + Table, + Id, +} + +#[derive(DeriveIden)] +enum PluginFailures { + Table, + Id, + PluginId, + ErrorMessage, + ErrorCode, + Method, + RequestId, + Context, + RequestSummary, + OccurredAt, + ExpiresAt, +} diff --git a/migration/src/m20260129_000036_seed_plugin_settings.rs b/migration/src/m20260129_000036_seed_plugin_settings.rs new file mode 100644 index 00000000..d9c3e1c8 --- /dev/null +++ b/migration/src/m20260129_000036_seed_plugin_settings.rs @@ -0,0 +1,139 @@ +use sea_orm::{entity::prelude::*, ActiveModelTrait, Set, Statement}; +use sea_orm_migration::prelude::*; +use uuid::Uuid; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// Define a minimal ActiveModel for settings to avoid circular dependencies +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "settings")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub key: String, + pub value: String, + pub value_type: String, + pub category: String, + pub description: String, + pub is_sensitive: bool, + pub default_value: String, + pub validation_rules: Option, + pub min_value: Option, + pub max_value: Option, + pub updated_at: chrono::DateTime, + pub updated_by: Option, + pub version: i32, + pub deleted_at: Option>, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // Check if the plugins.auto_match_confidence_threshold setting already exists (idempotent) + let exists_result = db + .query_one(Statement::from_string( + manager.get_database_backend(), + "SELECT COUNT(*) as count FROM settings WHERE key = 'plugins.auto_match_confidence_threshold'" + .to_owned(), + )) + .await?; + + let threshold_exists = if let Some(row) = exists_result { + let count: i64 = row.try_get("", "count")?; + count > 0 + } else { + false + }; + + // Seed plugins.auto_match_confidence_threshold setting + if !threshold_exists { + let setting = ActiveModel { + id: Set(Uuid::new_v4()), + key: Set("plugins.auto_match_confidence_threshold".to_string()), + value: Set("0.8".to_string()), + value_type: Set("Float".to_string()), + category: Set("Plugins".to_string()), + description: Set( + "Minimum relevance score (0.0-1.0) required for plugin auto-match to proceed. If the best search result has a relevance score below this threshold, the auto-match will be skipped. Set to 0 to always match regardless of score. If a plugin does not return relevance scores, auto-match proceeds anyway." + .to_string(), + ), + is_sensitive: Set(false), + default_value: Set("0.8".to_string()), + validation_rules: Set(None), + min_value: Set(None), // Float validation doesn't use min/max (they are i64) + max_value: Set(None), + updated_at: Set(chrono::Utc::now()), + updated_by: Set(None), + version: Set(1), + deleted_at: Set(None), + }; + + setting.insert(db).await?; + } + + // Check if the plugins.post_scan_auto_match_enabled setting already exists + let exists_result = db + .query_one(Statement::from_string( + manager.get_database_backend(), + "SELECT COUNT(*) as count FROM settings WHERE key = 'plugins.post_scan_auto_match_enabled'" + .to_owned(), + )) + .await?; + + let auto_match_exists = if let Some(row) = exists_result { + let count: i64 = row.try_get("", "count")?; + count > 0 + } else { + false + }; + + // Seed plugins.post_scan_auto_match_enabled setting + if !auto_match_exists { + let setting = ActiveModel { + id: Set(Uuid::new_v4()), + key: Set("plugins.post_scan_auto_match_enabled".to_string()), + value: Set("false".to_string()), + value_type: Set("Boolean".to_string()), + category: Set("Plugins".to_string()), + description: Set( + "Enable automatic metadata matching after library scans. When enabled, after a series is analyzed during a library scan, plugins with the 'library:scan' scope will automatically attempt to match and apply metadata. WARNING: This can trigger many API calls on large libraries. Disabled by default for safety." + .to_string(), + ), + is_sensitive: Set(false), + default_value: Set("false".to_string()), + validation_rules: Set(None), + min_value: Set(None), + max_value: Set(None), + updated_at: Set(chrono::Utc::now()), + updated_by: Set(None), + version: Set(1), + deleted_at: Set(None), + }; + + setting.insert(db).await?; + } + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + db.execute(Statement::from_string( + manager.get_database_backend(), + "DELETE FROM settings WHERE key IN ('plugins.auto_match_confidence_threshold', 'plugins.post_scan_auto_match_enabled')" + .to_owned(), + )) + .await?; + + Ok(()) + } +} diff --git a/migration/src/m20260130_000037_seed_thumbnail_cron_settings.rs b/migration/src/m20260130_000037_seed_thumbnail_cron_settings.rs new file mode 100644 index 00000000..1222e403 --- /dev/null +++ b/migration/src/m20260130_000037_seed_thumbnail_cron_settings.rs @@ -0,0 +1,131 @@ +use sea_orm::{entity::prelude::*, ActiveModelTrait, Set, Statement}; +use sea_orm_migration::prelude::*; +use uuid::Uuid; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// Define a minimal ActiveModel for settings to avoid circular dependencies +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "settings")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub key: String, + pub value: String, + pub value_type: String, + pub category: String, + pub description: String, + pub is_sensitive: bool, + pub default_value: String, + pub validation_rules: Option, + pub min_value: Option, + pub max_value: Option, + pub updated_at: chrono::DateTime, + pub updated_by: Option, + pub version: i32, + pub deleted_at: Option>, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // Seed thumbnail cron settings + let settings = vec![ + // Book thumbnail cron schedule + ( + "thumbnail.book_cron_schedule", + "", + "String", + "Thumbnail", + "Cron schedule for generating missing book thumbnails (e.g., '0 0 3 * * *' for daily at 3am). Leave empty to disable.", + false, + "", + None, + None, + Some(r#"{"input_type": "cron"}"#), + ), + // Series thumbnail cron schedule + ( + "thumbnail.series_cron_schedule", + "", + "String", + "Thumbnail", + "Cron schedule for generating missing series thumbnails (e.g., '0 0 4 * * *' for daily at 4am). Leave empty to disable.", + false, + "", + None, + None, + Some(r#"{"input_type": "cron"}"#), + ), + ]; + + for ( + key, + value, + value_type, + category, + description, + is_sensitive, + default_value, + min_val, + max_val, + validation_rules, + ) in settings + { + // Check if this setting already exists (idempotent) + let exists = db + .query_one(Statement::from_string( + manager.get_database_backend(), + format!("SELECT id FROM settings WHERE key = '{}'", key), + )) + .await?; + + if exists.is_some() { + continue; + } + + let setting = ActiveModel { + id: Set(Uuid::new_v4()), + key: Set(key.to_string()), + value: Set(value.to_string()), + value_type: Set(value_type.to_string()), + category: Set(category.to_string()), + description: Set(description.to_string()), + is_sensitive: Set(is_sensitive), + default_value: Set(default_value.to_string()), + validation_rules: Set(validation_rules.map(|s: &str| s.to_string())), + min_value: Set(min_val), + max_value: Set(max_val), + updated_at: Set(chrono::Utc::now()), + updated_by: Set(None), + version: Set(1), + deleted_at: Set(None), + }; + + setting.insert(db).await?; + } + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // Delete the seeded thumbnail cron settings + db.execute(Statement::from_string( + manager.get_database_backend(), + "DELETE FROM settings WHERE key IN ('thumbnail.book_cron_schedule', 'thumbnail.series_cron_schedule')".to_owned(), + )) + .await?; + + Ok(()) + } +} diff --git a/migration/src/m20260130_000038_update_settings_validation_rules.rs b/migration/src/m20260130_000038_update_settings_validation_rules.rs new file mode 100644 index 00000000..95daf2ce --- /dev/null +++ b/migration/src/m20260130_000038_update_settings_validation_rules.rs @@ -0,0 +1,77 @@ +use sea_orm::Statement; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // Update validation_rules for cron settings to include input_type + let cron_settings = vec!["deduplication.cron_schedule", "pdf_cache.cron_schedule"]; + + for key in cron_settings { + db.execute(Statement::from_string( + manager.get_database_backend(), + format!( + r#"UPDATE settings SET validation_rules = '{{"input_type": "cron"}}' WHERE key = '{}'"#, + key + ), + )) + .await?; + } + + // Update validation_rules for json settings + db.execute(Statement::from_string( + manager.get_database_backend(), + r#"UPDATE settings SET validation_rules = '{"input_type": "json"}' WHERE key = 'display.custom_metadata_template'"#.to_owned(), + )) + .await?; + + // Update validation_rules for select settings (metrics retention already has enum, add input_type) + db.execute(Statement::from_string( + manager.get_database_backend(), + r#"UPDATE settings SET validation_rules = '{"input_type": "select", "options": ["disabled", "7", "30", "90", "180"]}' WHERE key = 'metrics.task_retention_days'"#.to_owned(), + )) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // Reset validation_rules for cron settings + let cron_settings = vec!["deduplication.cron_schedule", "pdf_cache.cron_schedule"]; + + for key in cron_settings { + db.execute(Statement::from_string( + manager.get_database_backend(), + format!( + "UPDATE settings SET validation_rules = NULL WHERE key = '{}'", + key + ), + )) + .await?; + } + + // Reset validation_rules for json settings + db.execute(Statement::from_string( + manager.get_database_backend(), + "UPDATE settings SET validation_rules = NULL WHERE key = 'display.custom_metadata_template'" + .to_owned(), + )) + .await?; + + // Reset validation_rules for select settings (restore original enum format) + db.execute(Statement::from_string( + manager.get_database_backend(), + r#"UPDATE settings SET validation_rules = '{"enum": ["disabled", "7", "30", "90", "180"]}' WHERE key = 'metrics.task_retention_days'"#.to_owned(), + )) + .await?; + + Ok(()) + } +} diff --git a/plugins/.gitignore b/plugins/.gitignore new file mode 100644 index 00000000..b8eea088 --- /dev/null +++ b/plugins/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.d.ts diff --git a/plugins/biome.json b/plugins/biome.json new file mode 100644 index 00000000..d5eff078 --- /dev/null +++ b/plugins/biome.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": true, + "includes": ["**", "!**/dist/**", "!**/*.d.ts", "!**/openapi.json"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "noUnusedImports": "error", + "noUnusedVariables": "error" + }, + "style": { + "noNonNullAssertion": "warn", + "useConst": "error" + }, + "suspicious": { + "noExplicitAny": "error" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "always", + "trailingCommas": "all" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/plugins/metadata-echo/.gitignore b/plugins/metadata-echo/.gitignore new file mode 100644 index 00000000..e389b224 --- /dev/null +++ b/plugins/metadata-echo/.gitignore @@ -0,0 +1,11 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# TypeScript +*.tsbuildinfo + +# OS files +.DS_Store diff --git a/plugins/metadata-echo/README.md b/plugins/metadata-echo/README.md new file mode 100644 index 00000000..ada37e01 --- /dev/null +++ b/plugins/metadata-echo/README.md @@ -0,0 +1,212 @@ +# @codex/plugin-metadata-echo + +A minimal test metadata plugin for the Codex plugin system. Echoes back search queries and provides predictable responses for testing and development. + +## Purpose + +This plugin serves two purposes: + +1. **Protocol Validation**: Demonstrates correct implementation of the Codex plugin protocol +2. **Development Testing**: Provides predictable responses for testing the plugin UI without external API dependencies + +## Installation + +```bash +npm install -g @codex/plugin-metadata-echo +``` + +Or run directly with npx (no installation required). + +## Adding the Plugin to Codex + +### Using npx (Recommended) + +1. Log in to Codex as an administrator +2. Navigate to **Settings** > **Plugins** +3. Click **Add Plugin** +4. Fill in the form: + - **Name**: `metadata-echo` + - **Display Name**: `Echo Metadata Plugin` + - **Command**: `npx` + - **Arguments**: `-y @codex/plugin-metadata-echo@1.0.0` + - **Scopes**: Select `series:detail` +5. Click **Save** +6. Click **Test Connection** to verify the plugin works +7. Toggle **Enabled** to activate the plugin + +### npx Options + +| Configuration | Arguments | Description | +|--------------|-----------|-------------| +| Latest version | `-y @codex/plugin-metadata-echo` | Always uses latest | +| Pinned version | `-y @codex/plugin-metadata-echo@1.0.0` | Recommended for production | +| Fast startup | `-y --prefer-offline @codex/plugin-metadata-echo@1.0.0` | Skips version check if cached | + +**Flags:** +- `-y`: Auto-confirms installation (required for containers) +- `--prefer-offline`: Uses cached version without checking npm registry + +### Using Docker + +For Docker deployments, use npx with `--prefer-offline` for faster startup: + +``` +Command: npx +Arguments: -y --prefer-offline @codex/plugin-metadata-echo@1.0.0 +``` + +### Manual Installation (Alternative) + +For maximum performance, install globally: + +```bash +npm install -g @codex/plugin-metadata-echo +``` + +Then configure: +- **Command**: `codex-plugin-metadata-echo` +- **Arguments**: (leave empty) + +## Using the Plugin + +Once enabled, the Echo plugin appears in the series detail page: + +1. Navigate to any series in your library +2. Click the **Metadata** button (or look for the plugin icon) +3. Click **Search Echo Metadata Plugin** +4. Enter any search query +5. The plugin will echo your query back as search results +6. Select a result to see the preview +7. Click **Apply** to test the metadata apply flow + +This is useful for: +- Testing the plugin UI without needing real API credentials +- Verifying the metadata preview and apply workflow +- Debugging plugin integration issues + +## Response Behavior + +### Search (`metadata/search`) + +Returns two results for any query: + +1. **Primary result**: Title is `"Echo: {query}"` with `relevanceScore: 1.0` +2. **Secondary result**: Title is `"Echo Result 2 for: {query}"` with `relevanceScore: 0.8` + +### Get (`metadata/get`) + +Returns metadata with the external ID embedded in the title and URL: + +- Title: `"Echo Series: {externalId}"` +- External URL: `https://echo.example.com/series/{externalId}` +- Includes sample genres, tags, authors, and rating + +### Match (`metadata/match`) + +Returns a match based on the normalized title: + +- External ID: `match-{normalized-title}` +- Confidence: `0.85` +- Includes one alternative match + +## As a Reference Implementation + +Use this plugin as a template for building your own metadata plugins: + +```typescript +import { + createMetadataPlugin, + type MetadataProvider, +} from "@codex/plugin-sdk"; + +const provider: MetadataProvider = { + async search(params) { + return { + results: [ + { + externalId: "123", + title: "Example", + alternateTitles: [], + relevanceScore: 0.95, + preview: { + status: "ongoing", + genres: ["Action"], + }, + }, + ], + }; + }, + + async get(params) { + return { + externalId: params.externalId, + externalUrl: `https://example.com/${params.externalId}`, + alternateTitles: [], + genres: [], + tags: [], + authors: [], + artists: [], + externalLinks: [], + }; + }, + + // Optional: implement match for auto-matching + async match(params) { + return { + match: null, + confidence: 0, + }; + }, +}; + +createMetadataPlugin({ + manifest: { + name: "metadata-my-plugin", + displayName: "My Metadata Plugin", + version: "1.0.0", + description: "My custom metadata plugin", + author: "Me", + protocolVersion: "1.0", + capabilities: { metadataProvider: true }, + contentTypes: ["series"], + scopes: ["series:detail"], + }, + provider, +}); +``` + +## Development + +```bash +# Install dependencies +npm install + +# Build the plugin +npm run build + +# Type check +npm run typecheck + +# Run tests +npm test + +# Lint +npm run lint +``` + +## Project Structure + +``` +plugins/metadata-echo/ +├── src/ +│ └── index.ts # Plugin implementation +├── dist/ +│ └── index.js # Built bundle (excluded from git) +├── package.json +├── tsconfig.json +└── README.md +``` + +## License + +MIT diff --git a/plugins/metadata-echo/package-lock.json b/plugins/metadata-echo/package-lock.json new file mode 100644 index 00000000..03e5613b --- /dev/null +++ b/plugins/metadata-echo/package-lock.json @@ -0,0 +1,2277 @@ +{ + "name": "@codex/plugin-metadata-echo", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@codex/plugin-metadata-echo", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@codex/plugin-sdk": "file:../sdk-typescript" + }, + "bin": { + "plugin-metadata-echo": "dist/index.js" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.13", + "@types/node": "^22.0.0", + "esbuild": "^0.24.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "../sdk-typescript": { + "name": "@codex/plugin-sdk", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@biomejs/biome": "^2.3.11", + "@types/node": "^22.0.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.13.tgz", + "integrity": "sha512-Fw7UsV0UAtWIBIm0M7g5CRerpu1eKyKAXIazzxhbXYUyMkwNrkX/KLkGI7b+uVDQ5cLUMfOC9vR60q9IDYDstA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.3.13", + "@biomejs/cli-darwin-x64": "2.3.13", + "@biomejs/cli-linux-arm64": "2.3.13", + "@biomejs/cli-linux-arm64-musl": "2.3.13", + "@biomejs/cli-linux-x64": "2.3.13", + "@biomejs/cli-linux-x64-musl": "2.3.13", + "@biomejs/cli-win32-arm64": "2.3.13", + "@biomejs/cli-win32-x64": "2.3.13" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.13.tgz", + "integrity": "sha512-0OCwP0/BoKzyJHnFdaTk/i7hIP9JHH9oJJq6hrSCPmJPo8JWcJhprK4gQlhFzrwdTBAW4Bjt/RmCf3ZZe59gwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.13.tgz", + "integrity": "sha512-AGr8OoemT/ejynbIu56qeil2+F2WLkIjn2d8jGK1JkchxnMUhYOfnqc9sVzcRxpG9Ycvw4weQ5sprRvtb7Yhcw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.13.tgz", + "integrity": "sha512-xvOiFkrDNu607MPMBUQ6huHmBG1PZLOrqhtK6pXJW3GjfVqJg0Z/qpTdhXfcqWdSZHcT+Nct2fOgewZvytESkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.13.tgz", + "integrity": "sha512-TUdDCSY+Eo/EHjhJz7P2GnWwfqet+lFxBZzGHldrvULr59AgahamLs/N85SC4+bdF86EhqDuuw9rYLvLFWWlXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.13.tgz", + "integrity": "sha512-s+YsZlgiXNq8XkgHs6xdvKDFOj/bwTEevqEY6rC2I3cBHbxXYU1LOZstH3Ffw9hE5tE1sqT7U23C00MzkXztMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.13.tgz", + "integrity": "sha512-0bdwFVSbbM//Sds6OjtnmQGp4eUjOTt6kHvR/1P0ieR9GcTUAlPNvPC3DiavTqq302W34Ae2T6u5VVNGuQtGlQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.13.tgz", + "integrity": "sha512-QweDxY89fq0VvrxME+wS/BXKmqMrOTZlN9SqQ79kQSIc3FrEwvW/PvUegQF6XIVaekncDykB5dzPqjbwSKs9DA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.13.tgz", + "integrity": "sha512-trDw2ogdM2lyav9WFQsdsfdVy1dvZALymRpgmWsvSez0BJzBjulhOT/t+wyKeh3pZWvwP3VMs1SoOKwO3wecMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@codex/plugin-sdk": { + "resolved": "../sdk-typescript", + "link": true + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz", + "integrity": "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz", + "integrity": "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz", + "integrity": "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz", + "integrity": "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz", + "integrity": "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz", + "integrity": "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz", + "integrity": "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz", + "integrity": "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz", + "integrity": "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz", + "integrity": "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz", + "integrity": "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz", + "integrity": "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz", + "integrity": "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz", + "integrity": "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz", + "integrity": "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz", + "integrity": "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz", + "integrity": "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz", + "integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz", + "integrity": "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz", + "integrity": "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz", + "integrity": "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz", + "integrity": "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz", + "integrity": "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz", + "integrity": "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz", + "integrity": "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", + "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz", + "integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.0", + "@rollup/rollup-android-arm64": "4.57.0", + "@rollup/rollup-darwin-arm64": "4.57.0", + "@rollup/rollup-darwin-x64": "4.57.0", + "@rollup/rollup-freebsd-arm64": "4.57.0", + "@rollup/rollup-freebsd-x64": "4.57.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", + "@rollup/rollup-linux-arm-musleabihf": "4.57.0", + "@rollup/rollup-linux-arm64-gnu": "4.57.0", + "@rollup/rollup-linux-arm64-musl": "4.57.0", + "@rollup/rollup-linux-loong64-gnu": "4.57.0", + "@rollup/rollup-linux-loong64-musl": "4.57.0", + "@rollup/rollup-linux-ppc64-gnu": "4.57.0", + "@rollup/rollup-linux-ppc64-musl": "4.57.0", + "@rollup/rollup-linux-riscv64-gnu": "4.57.0", + "@rollup/rollup-linux-riscv64-musl": "4.57.0", + "@rollup/rollup-linux-s390x-gnu": "4.57.0", + "@rollup/rollup-linux-x64-gnu": "4.57.0", + "@rollup/rollup-linux-x64-musl": "4.57.0", + "@rollup/rollup-openbsd-x64": "4.57.0", + "@rollup/rollup-openharmony-arm64": "4.57.0", + "@rollup/rollup-win32-arm64-msvc": "4.57.0", + "@rollup/rollup-win32-ia32-msvc": "4.57.0", + "@rollup/rollup-win32-x64-gnu": "4.57.0", + "@rollup/rollup-win32-x64-msvc": "4.57.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/plugins/metadata-echo/package.json b/plugins/metadata-echo/package.json new file mode 100644 index 00000000..e93dee5e --- /dev/null +++ b/plugins/metadata-echo/package.json @@ -0,0 +1,50 @@ +{ + "name": "@codex/plugin-metadata-echo", + "version": "1.0.0", + "description": "Echo metadata plugin for testing Codex plugin protocol", + "main": "dist/index.js", + "bin": "dist/index.js", + "type": "module", + "files": [ + "dist", + "README.md" + ], + "repository": { + "type": "git", + "url": "https://github.com/AshDevFr/codex.git", + "directory": "plugins/metadata-echo" + }, + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --sourcemap --banner:js='#!/usr/bin/env node'", + "dev": "npm run build -- --watch", + "clean": "rm -rf dist", + "start": "node dist/index.js", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests", + "test:watch": "vitest", + "prepublishOnly": "npm run lint && npm run build" + }, + "keywords": [ + "codex", + "plugin", + "echo", + "testing" + ], + "author": "Codex", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + }, + "dependencies": { + "@codex/plugin-sdk": "file:../sdk-typescript" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.13", + "@types/node": "^22.0.0", + "esbuild": "^0.24.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/plugins/metadata-echo/src/index.ts b/plugins/metadata-echo/src/index.ts new file mode 100644 index 00000000..522a3c43 --- /dev/null +++ b/plugins/metadata-echo/src/index.ts @@ -0,0 +1,189 @@ +/** + * Echo Plugin - A minimal plugin for testing the Codex plugin protocol + * + * This plugin demonstrates the plugin SDK usage and serves as a protocol + * validation tool. It echoes back search parameters and provides predictable + * responses for testing. + */ + +import { + createMetadataPlugin, + type MetadataContentType, + type MetadataGetParams, + type MetadataMatchParams, + type MetadataMatchResponse, + type MetadataProvider, + type MetadataSearchParams, + type MetadataSearchResponse, + type PluginManifest, + type PluginSeriesMetadata, +} from "@codex/plugin-sdk"; + +const manifest = { + name: "metadata-echo", + displayName: "Echo Metadata Plugin", + version: "1.0.0", + description: "Test metadata plugin that echoes back search queries", + author: "Codex", + homepage: "https://github.com/AshDevFr/codex", + protocolVersion: "1.0", + capabilities: { + metadataProvider: ["series"] as MetadataContentType[], + }, +} as const satisfies PluginManifest & { + capabilities: { metadataProvider: MetadataContentType[] }; +}; + +const provider: MetadataProvider = { + async search(params: MetadataSearchParams): Promise { + // Echo back the query as search results + return { + results: [ + { + externalId: "echo-1", + title: `Echo: ${params.query}`, + alternateTitles: [`Echoed Query: ${params.query}`], + year: new Date().getFullYear(), + relevanceScore: 1.0, // Perfect match for echo + preview: { + status: "ended", + genres: ["Test", "Echo"], + rating: 10.0, + description: `Search query echoed: "${params.query}"`, + }, + }, + { + externalId: "echo-2", + title: `Echo Result 2 for: ${params.query}`, + alternateTitles: [], + relevanceScore: 0.8, + preview: { + status: "ongoing", + genres: ["Test"], + description: "A second result for testing pagination", + }, + }, + ], + }; + }, + + async get(params: MetadataGetParams): Promise { + // Return metadata based on the external ID with all fields populated for testing + return { + externalId: params.externalId, + externalUrl: `https://echo.example.com/series/${params.externalId}`, + title: `Echo Series: ${params.externalId}`, + alternateTitles: [ + { title: `Echo Series: ${params.externalId}`, language: "en", titleType: "english" }, + { title: `エコーシリーズ: ${params.externalId}`, language: "ja", titleType: "native" }, + { title: `Echo Romanized: ${params.externalId}`, language: "ja-Latn", titleType: "romaji" }, + ], + summary: `This is the full metadata for external ID: ${params.externalId}. It includes a detailed description to test summary handling.`, + status: "ended", + year: 2024, + + // Extended metadata fields + totalBookCount: 10, + language: "en", + ageRating: 13, + readingDirection: "ltr", + + // Taxonomy + genres: ["Action", "Comedy", "Test", "Echo"], + tags: ["plugin-test", "echo", "automation", "development"], + + // Credits + authors: ["Echo Author", "Test Writer"], + artists: ["Echo Artist"], + publisher: "Echo Publisher", + + // Media + coverUrl: "https://picsum.photos/300/450", + bannerUrl: "https://picsum.photos/800/200", + + // Primary rating + rating: { + score: 85, + voteCount: 100, + source: "echo", + }, + + // Multiple external ratings for testing aggregation + externalRatings: [ + { + score: 85, + voteCount: 100, + source: "echo", + }, + { + score: 92, + voteCount: 5000, + source: "anilist", + }, + { + score: 88, + voteCount: 2500, + source: "mal", + }, + ], + + // External links + externalLinks: [ + { + url: `https://echo.example.com/series/${params.externalId}`, + label: "Echo Provider", + linkType: "provider", + }, + { + url: "https://official-echo.example.com", + label: "Official Site", + linkType: "official", + }, + { + url: "https://twitter.com/echo_series", + label: "Twitter", + linkType: "social", + }, + { + url: "https://store.example.com/echo", + label: "Buy", + linkType: "purchase", + }, + ], + }; + }, + + async match(params: MetadataMatchParams): Promise { + // Return a match based on the title + const normalizedTitle = params.title.toLowerCase().replace(/\s+/g, "-"); + return { + match: { + externalId: `match-${normalizedTitle}`, + title: params.title, + alternateTitles: [], + year: params.year, + relevanceScore: 0.9, + preview: { + status: "ended", + genres: ["Matched"], + description: `Matched from title: "${params.title}"`, + }, + }, + confidence: 0.85, + alternatives: [ + { + externalId: "alt-1", + title: `Alternative: ${params.title}`, + alternateTitles: [], + relevanceScore: 0.6, + }, + ], + }; + }, +}; + +createMetadataPlugin({ + manifest, + provider, + logLevel: "debug", +}); diff --git a/plugins/metadata-echo/tsconfig.json b/plugins/metadata-echo/tsconfig.json new file mode 100644 index 00000000..ce96b72e --- /dev/null +++ b/plugins/metadata-echo/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/plugins/metadata-mangabaka/.gitignore b/plugins/metadata-mangabaka/.gitignore new file mode 100644 index 00000000..e389b224 --- /dev/null +++ b/plugins/metadata-mangabaka/.gitignore @@ -0,0 +1,11 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# TypeScript +*.tsbuildinfo + +# OS files +.DS_Store diff --git a/plugins/metadata-mangabaka/README.md b/plugins/metadata-mangabaka/README.md new file mode 100644 index 00000000..b08ee169 --- /dev/null +++ b/plugins/metadata-mangabaka/README.md @@ -0,0 +1,260 @@ +# @codex/plugin-metadata-mangabaka + +A Codex metadata plugin for fetching manga metadata from [MangaBaka](https://mangabaka.org). MangaBaka aggregates metadata from multiple sources including AniList, MyAnimeList, MangaDex, and more. + +## Features + +- Search for manga/manhwa/manhua by title +- Fetch comprehensive metadata including: + - Titles in multiple languages (English, Japanese, Korean, Chinese) + - Synopsis/description + - Publication status (ongoing, completed, hiatus, cancelled) + - Genres and tags + - Authors and artists + - Cover images + - Ratings + - External links to AniList, MAL, MangaDex + +## Prerequisites + +You need a MangaBaka API key to use this plugin: + +1. Create an account at [mangabaka.org](https://mangabaka.org) +2. Go to [Settings > API](https://mangabaka.org/settings/api) +3. Generate an API key + +## Installation + +```bash +npm install -g @codex/plugin-metadata-mangabaka +``` + +Or run directly with npx (no installation required). + +## Adding the Plugin to Codex + +### Using npx (Recommended) + +1. Log in to Codex as an administrator +2. Navigate to **Settings** > **Plugins** +3. Click **Add Plugin** +4. Fill in the form: + - **Name**: `metadata-mangabaka` + - **Display Name**: `MangaBaka Metadata` + - **Command**: `npx` + - **Arguments**: `-y @codex/plugin-metadata-mangabaka@1.0.0` + - **Scopes**: Select `series:detail` +5. In the **Credentials** tab: + - **Credential Delivery**: Select `Initialize Message` or `Both` + - **Credentials**: `{"api_key": "your-mangabaka-api-key"}` +6. Click **Save** +7. Click **Test Connection** to verify the plugin works +8. Toggle **Enabled** to activate the plugin + +### npx Options + +| Configuration | Arguments | Description | +|--------------|-----------|-------------| +| Latest version | `-y @codex/plugin-metadata-mangabaka` | Always uses latest | +| Pinned version | `-y @codex/plugin-metadata-mangabaka@1.0.0` | Recommended for production | +| Fast startup | `-y --prefer-offline @codex/plugin-metadata-mangabaka@1.0.0` | Skips version check if cached | + +**Flags:** +- `-y`: Auto-confirms installation (required for containers) +- `--prefer-offline`: Uses cached version without checking npm registry + +### Using Docker + +For Docker deployments, use npx with `--prefer-offline` for faster startup: + +``` +Command: npx +Arguments: -y --prefer-offline @codex/plugin-metadata-mangabaka@1.0.0 +``` + +Pre-warm the cache in your Dockerfile: + +```dockerfile +# Pre-cache plugin during image build +RUN npx -y @codex/plugin-metadata-mangabaka@1.0.0 --version || true +``` + +### Manual Installation (Alternative) + +For maximum performance, install globally: + +```bash +npm install -g @codex/plugin-metadata-mangabaka +``` + +Then configure: +- **Command**: `codex-plugin-metadata-mangabaka` +- **Arguments**: (leave empty) + +## Configuration + +### Credentials + +The plugin requires a MangaBaka API key. Configure it in the Codex UI or via the API: + +```json +{ + "api_key": "mb-123412341234" +} +``` + +### Credential Delivery Method + +This plugin receives credentials via the `initialize` message, so you must set the **Credential Delivery** option appropriately: + +| Method | Value | Description | +|--------|-------|-------------| +| Initialize Message | `init_message` | Credentials passed in the JSON-RPC `initialize` request (recommended) | +| Both | `both` | Credentials passed as both environment variables and in `initialize` | + +**Note:** The `env` (environment variables only) method will **not work** with this plugin because it reads credentials from the `onInitialize` callback, not from environment variables. + +### Parameters + +The plugin supports optional parameters to customize behavior: + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `base_url` | string | `https://api.mangabaka.org` | Override the API base URL | + +Example parameters configuration: + +```json +{ + "base_url": "https://api.mangabaka.org" +} +``` + +### Rate Limiting + +The plugin automatically handles rate limiting from the MangaBaka API: + +- When rate limited (HTTP 429), the plugin returns a `RateLimitError` with the retry delay +- The `Retry-After` header is used to determine wait time (defaults to 60 seconds) +- Codex will automatically retry requests after the specified delay + +If you encounter frequent rate limiting, consider spacing out your metadata refresh operations. + +## Using the Plugin + +Once enabled, the MangaBaka plugin appears in the series detail page: + +1. Navigate to any series in your library +2. Click the **Metadata** button (or look for the plugin icon) +3. Click **Search MangaBaka Metadata** +4. Enter the series title to search +5. Select the best match from the results +6. Preview the metadata changes +7. Click **Apply** to update your series metadata + +The plugin will show: +- **Will Apply**: Fields that will be updated +- **Locked**: Fields you've locked (won't be changed) +- **Unchanged**: Fields that already match + +## Development + +```bash +# Install dependencies +npm install + +# Build the plugin +npm run build + +# Type check +npm run typecheck + +# Run tests +npm test + +# Lint +npm run lint +``` + +## Project Structure + +``` +plugins/metadata-mangabaka/ +├── src/ +│ ├── index.ts # Plugin entry point +│ ├── manifest.ts # Plugin manifest +│ ├── api.ts # MangaBaka API client +│ ├── mappers.ts # Response mappers +│ ├── types.ts # MangaBaka API types +│ └── handlers/ +│ ├── search.ts # Search handler +│ ├── get.ts # Get metadata handler +│ └── match.ts # Auto-match handler +├── dist/ +│ └── index.js # Built bundle (excluded from git) +├── package.json +├── tsconfig.json +└── README.md +``` + +## API Reference + +### Search + +Searches MangaBaka for series matching a query. + +**Parameters:** +- `query`: Search string +- `contentType`: Always `"series"` +- `limit`: Max results (default: 20) +- `cursor`: Page cursor for pagination + +**Returns:** +- `results`: Array of search results with `relevanceScore` (0.0-1.0) +- `nextCursor`: Cursor for next page (if available) + +### Get + +Fetches full metadata for a specific series. + +**Parameters:** +- `externalId`: MangaBaka series ID +- `contentType`: Always `"series"` + +**Returns:** +- Full series metadata including titles, summary, genres, etc. + +### Match + +Finds the best match for an existing series (used for auto-matching). + +**Parameters:** +- `title`: Series title to match +- `year`: Publication year (optional hint) +- `contentType`: Always `"series"` + +**Returns:** +- `match`: Best matching result or `null` +- `confidence`: Match confidence (0.0-1.0) +- `alternatives`: Other potential matches if confidence is low + +## Troubleshooting + +### "api_key credential is required" + +Make sure you've configured the API key in the plugin credentials section. + +### "Plugin not initialized" + +The plugin hasn't received credentials yet. Check that: +1. The plugin is properly configured in Settings > Plugins +2. Credentials are saved +3. Try disabling and re-enabling the plugin + +### "Rate limited" + +MangaBaka has API rate limits. The plugin will report the retry delay from the API. Wait for the specified time before retrying. See the [Rate Limiting](#rate-limiting) section for more details. + +## License + +MIT diff --git a/plugins/metadata-mangabaka/package-lock.json b/plugins/metadata-mangabaka/package-lock.json new file mode 100644 index 00000000..2631361e --- /dev/null +++ b/plugins/metadata-mangabaka/package-lock.json @@ -0,0 +1,2277 @@ +{ + "name": "@codex/plugin-metadata-mangabaka", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@codex/plugin-metadata-mangabaka", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@codex/plugin-sdk": "file:../sdk-typescript" + }, + "bin": { + "plugin-metadata-mangabaka": "dist/index.js" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.11", + "@types/node": "^22.0.0", + "esbuild": "^0.24.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "../sdk-typescript": { + "name": "@codex/plugin-sdk", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@biomejs/biome": "^2.3.11", + "@types/node": "^22.0.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.11.tgz", + "integrity": "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.3.11", + "@biomejs/cli-darwin-x64": "2.3.11", + "@biomejs/cli-linux-arm64": "2.3.11", + "@biomejs/cli-linux-arm64-musl": "2.3.11", + "@biomejs/cli-linux-x64": "2.3.11", + "@biomejs/cli-linux-x64-musl": "2.3.11", + "@biomejs/cli-win32-arm64": "2.3.11", + "@biomejs/cli-win32-x64": "2.3.11" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.11.tgz", + "integrity": "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.11.tgz", + "integrity": "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.11.tgz", + "integrity": "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.11.tgz", + "integrity": "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.11.tgz", + "integrity": "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.11.tgz", + "integrity": "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.11.tgz", + "integrity": "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.11.tgz", + "integrity": "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@codex/plugin-sdk": { + "resolved": "../sdk-typescript", + "link": true + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz", + "integrity": "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz", + "integrity": "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz", + "integrity": "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz", + "integrity": "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz", + "integrity": "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz", + "integrity": "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz", + "integrity": "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz", + "integrity": "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz", + "integrity": "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz", + "integrity": "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz", + "integrity": "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz", + "integrity": "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz", + "integrity": "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz", + "integrity": "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz", + "integrity": "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz", + "integrity": "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz", + "integrity": "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz", + "integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz", + "integrity": "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz", + "integrity": "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz", + "integrity": "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz", + "integrity": "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz", + "integrity": "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz", + "integrity": "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz", + "integrity": "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", + "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz", + "integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.0", + "@rollup/rollup-android-arm64": "4.57.0", + "@rollup/rollup-darwin-arm64": "4.57.0", + "@rollup/rollup-darwin-x64": "4.57.0", + "@rollup/rollup-freebsd-arm64": "4.57.0", + "@rollup/rollup-freebsd-x64": "4.57.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", + "@rollup/rollup-linux-arm-musleabihf": "4.57.0", + "@rollup/rollup-linux-arm64-gnu": "4.57.0", + "@rollup/rollup-linux-arm64-musl": "4.57.0", + "@rollup/rollup-linux-loong64-gnu": "4.57.0", + "@rollup/rollup-linux-loong64-musl": "4.57.0", + "@rollup/rollup-linux-ppc64-gnu": "4.57.0", + "@rollup/rollup-linux-ppc64-musl": "4.57.0", + "@rollup/rollup-linux-riscv64-gnu": "4.57.0", + "@rollup/rollup-linux-riscv64-musl": "4.57.0", + "@rollup/rollup-linux-s390x-gnu": "4.57.0", + "@rollup/rollup-linux-x64-gnu": "4.57.0", + "@rollup/rollup-linux-x64-musl": "4.57.0", + "@rollup/rollup-openbsd-x64": "4.57.0", + "@rollup/rollup-openharmony-arm64": "4.57.0", + "@rollup/rollup-win32-arm64-msvc": "4.57.0", + "@rollup/rollup-win32-ia32-msvc": "4.57.0", + "@rollup/rollup-win32-x64-gnu": "4.57.0", + "@rollup/rollup-win32-x64-msvc": "4.57.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/plugins/metadata-mangabaka/package.json b/plugins/metadata-mangabaka/package.json new file mode 100644 index 00000000..935a1cf9 --- /dev/null +++ b/plugins/metadata-mangabaka/package.json @@ -0,0 +1,51 @@ +{ + "name": "@codex/plugin-metadata-mangabaka", + "version": "1.0.0", + "description": "MangaBaka metadata provider plugin for Codex", + "main": "dist/index.js", + "bin": "dist/index.js", + "type": "module", + "files": [ + "dist", + "README.md" + ], + "repository": { + "type": "git", + "url": "https://github.com/AshDevFr/codex.git", + "directory": "plugins/metadata-mangabaka" + }, + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --sourcemap --banner:js='#!/usr/bin/env node'", + "dev": "npm run build -- --watch", + "clean": "rm -rf dist", + "start": "node dist/index.js", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "prepublishOnly": "npm run lint && npm run build" + }, + "keywords": [ + "codex", + "plugin", + "mangabaka", + "manga", + "metadata" + ], + "author": "Codex", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + }, + "dependencies": { + "@codex/plugin-sdk": "file:../sdk-typescript" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.11", + "@types/node": "^22.0.0", + "esbuild": "^0.24.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/plugins/metadata-mangabaka/src/api.ts b/plugins/metadata-mangabaka/src/api.ts new file mode 100644 index 00000000..86bc25ee --- /dev/null +++ b/plugins/metadata-mangabaka/src/api.ts @@ -0,0 +1,123 @@ +/** + * MangaBaka API client + * API docs: https://mangabaka.org/api + */ + +import { + ApiError, + AuthError, + createLogger, + NotFoundError, + RateLimitError, +} from "@codex/plugin-sdk"; +import type { MbGetSeriesResponse, MbSearchResponse, MbSeries } from "./types.js"; + +const BASE_URL = "https://api.mangabaka.dev"; +const logger = createLogger({ name: "mangabaka-api", level: "debug" }); + +export class MangaBakaClient { + private readonly apiKey: string; + + constructor(apiKey: string) { + if (!apiKey) { + throw new AuthError("API key is required"); + } + this.apiKey = apiKey; + } + + /** + * Search for series by query + */ + async search( + query: string, + page = 1, + perPage = 20, + ): Promise<{ data: MbSeries[]; total: number; page: number; totalPages: number }> { + logger.debug(`Searching for: "${query}" (page ${page})`); + + const params = new URLSearchParams({ + q: query, + page: String(page), + limit: String(perPage), + }); + + const response = await this.request(`/v1/series/search?${params.toString()}`); + + return { + data: response.data, + total: response.pagination?.total ?? response.data.length, + page: response.pagination?.page ?? page, + totalPages: response.pagination?.total_pages ?? 1, + }; + } + + /** + * Get full series details by ID + */ + async getSeries(id: number): Promise { + logger.debug(`Getting series: ${id}`); + + const response = await this.request(`/v1/series/${id}`); + + return response.data; + } + + /** + * Make an authenticated request to the MangaBaka API + */ + private async request(path: string): Promise { + const url = `${BASE_URL}${path}`; + const headers: Record = { + "x-api-key": this.apiKey, + Accept: "application/json", + }; + + try { + const response = await fetch(url, { + method: "GET", + headers, + }); + + // Handle rate limiting + if (response.status === 429) { + const retryAfter = response.headers.get("Retry-After"); + const seconds = retryAfter ? Number.parseInt(retryAfter, 10) : 60; + throw new RateLimitError(seconds); + } + + // Handle auth errors + if (response.status === 401 || response.status === 403) { + throw new AuthError("Invalid API key"); + } + + // Handle not found + if (response.status === 404) { + throw new NotFoundError(`Resource not found: ${path}`); + } + + // Handle other errors + if (!response.ok) { + const text = await response.text(); + logger.error(`API error: ${response.status}`, { body: text }); + throw new ApiError(`API error: ${response.status} ${response.statusText}`, response.status); + } + + return response.json() as Promise; + } catch (error) { + // Re-throw plugin errors + if ( + error instanceof RateLimitError || + error instanceof AuthError || + error instanceof NotFoundError || + error instanceof ApiError + ) { + throw error; + } + + // Wrap other errors + const message = error instanceof Error ? error.message : "Unknown error"; + logger.error("Request failed", error); + throw new ApiError(`Request failed: ${message}`); + } + } +} diff --git a/plugins/metadata-mangabaka/src/handlers/get.ts b/plugins/metadata-mangabaka/src/handlers/get.ts new file mode 100644 index 00000000..65cdf30d --- /dev/null +++ b/plugins/metadata-mangabaka/src/handlers/get.ts @@ -0,0 +1,22 @@ +import { + type MetadataGetParams, + NotFoundError, + type PluginSeriesMetadata, +} from "@codex/plugin-sdk"; +import type { MangaBakaClient } from "../api.js"; +import { mapSeriesMetadata } from "../mappers.js"; + +export async function handleGet( + params: MetadataGetParams, + client: MangaBakaClient, +): Promise { + const seriesId = Number.parseInt(params.externalId, 10); + + if (Number.isNaN(seriesId)) { + throw new NotFoundError(`Invalid external ID: ${params.externalId}`); + } + + const response = await client.getSeries(seriesId); + + return mapSeriesMetadata(response); +} diff --git a/plugins/metadata-mangabaka/src/handlers/match.ts b/plugins/metadata-mangabaka/src/handlers/match.ts new file mode 100644 index 00000000..f52fba75 --- /dev/null +++ b/plugins/metadata-mangabaka/src/handlers/match.ts @@ -0,0 +1,116 @@ +import { + createLogger, + type MetadataMatchParams, + type MetadataMatchResponse, + type SearchResult, +} from "@codex/plugin-sdk"; +import type { MangaBakaClient } from "../api.js"; +import { mapSearchResult } from "../mappers.js"; + +const logger = createLogger({ name: "mangabaka-match", level: "info" }); + +/** + * Calculate string similarity using word overlap + * Returns a value between 0 and 1 + */ +function similarity(a: string, b: string): number { + const aLower = a.toLowerCase().trim(); + const bLower = b.toLowerCase().trim(); + + if (aLower === bLower) return 1.0; + if (aLower.length === 0 || bLower.length === 0) return 0; + + // Check if one contains the other + if (aLower.includes(bLower) || bLower.includes(aLower)) { + return 0.8; + } + + // Simple word overlap scoring + const aWords = new Set(aLower.split(/\s+/)); + const bWords = new Set(bLower.split(/\s+/)); + const intersection = [...aWords].filter((w) => bWords.has(w)); + const union = new Set([...aWords, ...bWords]); + + return intersection.length / union.size; +} + +/** + * Score a search result against the match parameters + * Returns a value between 0 and 1 + */ +function scoreResult(result: SearchResult, params: MetadataMatchParams): number { + let score = 0; + + // Title similarity (up to 0.6) + const titleScore = similarity(result.title, params.title); + score += titleScore * 0.6; + + // Year match (up to 0.2) + if (params.year && result.year) { + if (result.year === params.year) { + score += 0.2; + } else if (Math.abs(result.year - params.year) <= 1) { + score += 0.1; + } + } + + // Boost for exact title match (up to 0.2) + if (result.title.toLowerCase() === params.title.toLowerCase()) { + score += 0.2; + } + + return Math.min(1.0, score); +} + +export async function handleMatch( + params: MetadataMatchParams, + client: MangaBakaClient, +): Promise { + logger.debug(`Matching: "${params.title}"`); + + // Search for the title + const response = await client.search(params.title, 1, 10); + + if (response.data.length === 0) { + return { + match: null, + confidence: 0, + }; + } + + // Map and score results + const scoredResults = response.data + .map((series) => { + const result = mapSearchResult(series); + const score = scoreResult(result, params); + return { result, score }; + }) + .sort((a, b) => b.score - a.score); + + const best = scoredResults[0]; + + if (!best) { + return { + match: null, + confidence: 0, + }; + } + + // If confidence is low, include alternatives + const alternatives = + best.score < 0.8 + ? scoredResults.slice(1, 4).map((s) => ({ + ...s.result, + relevanceScore: s.score, + })) + : undefined; + + return { + match: { + ...best.result, + relevanceScore: best.score, + }, + confidence: best.score, + alternatives, + }; +} diff --git a/plugins/metadata-mangabaka/src/handlers/search.ts b/plugins/metadata-mangabaka/src/handlers/search.ts new file mode 100644 index 00000000..59b88750 --- /dev/null +++ b/plugins/metadata-mangabaka/src/handlers/search.ts @@ -0,0 +1,37 @@ +import { + createLogger, + type MetadataSearchParams, + type MetadataSearchResponse, +} from "@codex/plugin-sdk"; +import type { MangaBakaClient } from "../api.js"; +import { mapSearchResult } from "../mappers.js"; + +const logger = createLogger({ name: "mangabaka-search", level: "debug" }); + +export async function handleSearch( + params: MetadataSearchParams, + client: MangaBakaClient, +): Promise { + logger.debug("Search params received:", params); + + const limit = params.limit ?? 20; + + // Parse cursor as page number (default to 1) + const page = params.cursor ? Number.parseInt(params.cursor, 10) : 1; + + logger.debug(`Searching for: "${params.query}" (page ${page}, limit ${limit})`); + + const response = await client.search(params.query, page, limit); + + // Map results - API already returns them sorted by relevance + const results = response.data.map(mapSearchResult); + + // Calculate next cursor (next page number) if there are more results + const hasNextPage = response.page < response.totalPages; + const nextCursor = hasNextPage ? String(response.page + 1) : undefined; + + return { + results, + nextCursor, + }; +} diff --git a/plugins/metadata-mangabaka/src/index.ts b/plugins/metadata-mangabaka/src/index.ts new file mode 100644 index 00000000..60de912b --- /dev/null +++ b/plugins/metadata-mangabaka/src/index.ts @@ -0,0 +1,68 @@ +/** + * MangaBaka Plugin - Fetch manga metadata from MangaBaka + * + * MangaBaka aggregates metadata from multiple sources (AniList, MAL, MangaDex, etc.) + * and provides a unified API for manga/novel metadata. + * + * API docs: https://mangabaka.org/api + * + * Credentials are provided by Codex via the initialize message. + * Required credential: api_key (get one at https://mangabaka.org/settings/api) + */ + +import { + ConfigError, + createLogger, + createMetadataPlugin, + type InitializeParams, + type MetadataProvider, +} from "@codex/plugin-sdk"; +import { MangaBakaClient } from "./api.js"; +import { handleGet } from "./handlers/get.js"; +import { handleMatch } from "./handlers/match.js"; +import { handleSearch } from "./handlers/search.js"; +import { manifest } from "./manifest.js"; + +const logger = createLogger({ name: "mangabaka", level: "info" }); + +// Client is initialized when we receive credentials from Codex +let client: MangaBakaClient | null = null; + +function getClient(): MangaBakaClient { + if (!client) { + throw new ConfigError("Plugin not initialized - missing API key"); + } + return client; +} + +// Create the MetadataProvider implementation +const provider: MetadataProvider = { + async search(params) { + return handleSearch(params, getClient()); + }, + + async get(params) { + return handleGet(params, getClient()); + }, + + async match(params) { + return handleMatch(params, getClient()); + }, +}; + +// Start the plugin server +createMetadataPlugin({ + manifest, + provider, + logLevel: "info", + onInitialize(params: InitializeParams) { + const apiKey = params.credentials?.api_key; + if (!apiKey) { + throw new ConfigError("api_key credential is required"); + } + client = new MangaBakaClient(apiKey); + logger.info("MangaBaka client initialized"); + }, +}); + +logger.info("MangaBaka plugin started"); diff --git a/plugins/metadata-mangabaka/src/manifest.ts b/plugins/metadata-mangabaka/src/manifest.ts new file mode 100644 index 00000000..60006323 --- /dev/null +++ b/plugins/metadata-mangabaka/src/manifest.ts @@ -0,0 +1,27 @@ +import type { MetadataContentType, PluginManifest } from "@codex/plugin-sdk"; + +export const manifest = { + name: "metadata-mangabaka", + displayName: "MangaBaka Metadata", + version: "1.0.0", + description: "Fetch manga metadata from MangaBaka - aggregated data from multiple sources", + author: "Codex", + homepage: "https://mangabaka.org", + protocolVersion: "1.0", + capabilities: { + metadataProvider: ["series"] as MetadataContentType[], + }, + requiredCredentials: [ + { + key: "api_key", + label: "API Key", + description: "Get your API key at https://mangabaka.org/settings/api (requires account)", + required: true, + sensitive: true, + type: "password", + placeholder: "mb-...", + }, + ], +} as const satisfies PluginManifest & { + capabilities: { metadataProvider: MetadataContentType[] }; +}; diff --git a/plugins/metadata-mangabaka/src/mappers.test.ts b/plugins/metadata-mangabaka/src/mappers.test.ts new file mode 100644 index 00000000..1921008a --- /dev/null +++ b/plugins/metadata-mangabaka/src/mappers.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it } from "vitest"; +import { mapSearchResult, mapSeriesMetadata } from "./mappers.js"; +import type { MbSeries } from "./types.js"; + +describe("mappers", () => { + describe("mapSearchResult", () => { + it("should map a series to SearchResult", () => { + const series: MbSeries = { + id: 12345, + state: "active", + title: "Test Manga", + native_title: "テストマンガ", + romanized_title: "Tesuto Manga", + cover: { + raw: { url: "https://cdn.mangabaka.org/covers/12345.jpg" }, + x150: { x1: null, x2: null, x3: null }, + x250: { x1: "https://cdn.mangabaka.org/covers/12345_250.jpg", x2: null, x3: null }, + x350: { x1: null, x2: null, x3: null }, + }, + description: "A test manga description", + type: "manga", + year: 2020, + status: "releasing", + genres: ["action", "adventure"], + rating: { + bayesian: 8.5, + }, + }; + + const result = mapSearchResult(series); + + expect(result.externalId).toBe("12345"); + expect(result.title).toBe("Test Manga"); + expect(result.alternateTitles).toContain("テストマンガ"); + expect(result.alternateTitles).toContain("Tesuto Manga"); + expect(result.year).toBe(2020); + expect(result.coverUrl).toBe("https://cdn.mangabaka.org/covers/12345_250.jpg"); + expect(result.preview?.status).toBe("ongoing"); + expect(result.preview?.rating).toBe(8.5); + expect(result.preview?.description).toBe("A test manga description"); + // relevanceScore is not set - API returns results in relevance order + expect(result.relevanceScore).toBeUndefined(); + }); + + it("should handle missing fields gracefully", () => { + const series: MbSeries = { + id: 99999, + state: "active", + title: "Minimal Entry", + cover: { + raw: { url: null }, + x150: { x1: null, x2: null, x3: null }, + x250: { x1: null, x2: null, x3: null }, + x350: { x1: null, x2: null, x3: null }, + }, + type: "manga", + status: "unknown", + }; + + const result = mapSearchResult(series); + + expect(result.externalId).toBe("99999"); + expect(result.title).toBe("Minimal Entry"); + expect(result.year).toBeUndefined(); + expect(result.coverUrl).toBeUndefined(); + expect(result.preview?.rating).toBeUndefined(); + expect(result.relevanceScore).toBeUndefined(); + }); + }); + + describe("mapSeriesMetadata", () => { + it("should map full series response to PluginSeriesMetadata", () => { + const series: MbSeries = { + id: 12345, + state: "active", + title: "Test Manga", + native_title: "テストマンガ", + romanized_title: "Tesuto Manga", + secondary_titles: { + en: [{ type: "alternative", title: "Test Manga: Subtitle" }], + ja: [{ type: "native", title: "テストマンガ外伝" }], + }, + cover: { + raw: { url: "https://cdn.mangabaka.org/covers/12345.jpg" }, + x150: { x1: null, x2: null, x3: null }, + x250: { x1: null, x2: null, x3: null }, + x350: { x1: "https://cdn.mangabaka.org/covers/12345_350.jpg", x2: null, x3: null }, + }, + description: "A great manga about testing.", + type: "manga", + year: 2020, + status: "releasing", + is_licensed: true, + has_anime: true, + country_of_origin: "jp", + content_rating: "safe", + genres: ["action", "drama"], + tags: ["Strong Lead", "Time Travel"], + authors: ["Test Author"], + artists: ["Test Artist"], + source: { + anilist: { id: 111, rating: 8.3, rating_normalized: 83 }, + my_anime_list: { id: 222, rating: 7.96, rating_normalized: 80 }, + }, + rating: { + bayesian: 8.75, + }, + }; + + const result = mapSeriesMetadata(series); + + expect(result.externalId).toBe("12345"); + expect(result.externalUrl).toBe("https://mangabaka.org/12345"); + expect(result.title).toBe("Test Manga"); + + // Check alternate titles + expect(result.alternateTitles.length).toBeGreaterThanOrEqual(2); + expect(result.alternateTitles).toContainEqual({ + title: "テストマンガ", + language: "ja", + titleType: "native", + }); + expect(result.alternateTitles).toContainEqual({ + title: "Tesuto Manga", + language: "en", + titleType: "romaji", + }); + + expect(result.summary).toBe("A great manga about testing."); + expect(result.status).toBe("ongoing"); + expect(result.year).toBe(2020); + expect(result.genres).toEqual(["Action", "Drama"]); + expect(result.tags).toEqual(["Strong Lead", "Time Travel"]); + expect(result.authors).toEqual(["Test Author"]); + expect(result.artists).toEqual(["Test Artist"]); + expect(result.rating).toEqual({ score: 8.75, source: "mangabaka" }); + + // Check external links + expect(result.externalLinks).toContainEqual({ + label: "MangaBaka", + url: "https://mangabaka.org/12345", + linkType: "provider", + }); + expect(result.externalLinks).toContainEqual({ + label: "AniList", + url: "https://anilist.co/manga/111", + linkType: "provider", + }); + expect(result.externalLinks).toContainEqual({ + label: "MyAnimeList", + url: "https://myanimelist.net/manga/222", + linkType: "provider", + }); + + // Check external ratings are extracted dynamically + expect(result.externalRatings).toContainEqual({ score: 83, source: "anilist" }); + expect(result.externalRatings).toContainEqual({ score: 80, source: "myanimelist" }); + }); + + it("should dynamically extract all external ratings from sources", () => { + const series: MbSeries = { + id: 1668, + state: "active", + title: "Test Series with Many Sources", + cover: { + raw: { url: null }, + x150: { x1: null, x2: null, x3: null }, + x250: { x1: null, x2: null, x3: null }, + x350: { x1: null, x2: null, x3: null }, + }, + type: "manga", + status: "completed", + source: { + anilist: { id: 31668, rating: 8.3, rating_normalized: 83 }, + anime_planet: { id: "test-manga", rating: 4, rating_normalized: 80 }, + anime_news_network: { id: null, rating: null, rating_normalized: null }, + kitsu: { id: 3556, rating: 7.759, rating_normalized: 78 }, + manga_updates: { id: "hly6oqa", rating: 7.74, rating_normalized: 77 }, + my_anime_list: { id: 1668, rating: 7.96, rating_normalized: 80 }, + shikimori: { id: 1668, rating: 7.96, rating_normalized: 80 }, + }, + }; + + const result = mapSeriesMetadata(series); + + // Should include all sources with valid rating_normalized (excluding anime_news_network which has null) + expect(result.externalRatings).toHaveLength(6); + expect(result.externalRatings).toContainEqual({ score: 83, source: "anilist" }); + expect(result.externalRatings).toContainEqual({ score: 80, source: "animeplanet" }); + expect(result.externalRatings).toContainEqual({ score: 78, source: "kitsu" }); + expect(result.externalRatings).toContainEqual({ score: 77, source: "mangaupdates" }); + expect(result.externalRatings).toContainEqual({ score: 80, source: "myanimelist" }); + expect(result.externalRatings).toContainEqual({ score: 80, source: "shikimori" }); + + // Should NOT include anime_news_network (rating_normalized is null) + expect(result.externalRatings).not.toContainEqual( + expect.objectContaining({ source: "animenewsnetwork" }), + ); + }); + + it("should map completed series correctly", () => { + const series: MbSeries = { + id: 1, + state: "active", + title: "Completed Manga", + cover: { + raw: { url: null }, + x150: { x1: null, x2: null, x3: null }, + x250: { x1: null, x2: null, x3: null }, + x350: { x1: null, x2: null, x3: null }, + }, + type: "manga", + year: 2010, + status: "completed", + }; + + const result = mapSeriesMetadata(series); + + // "completed" from MangaBaka maps to "ended" in Codex + expect(result.status).toBe("ended"); + expect(result.year).toBe(2010); + }); + + it("should map cancelled series to abandoned", () => { + const series: MbSeries = { + id: 3, + state: "active", + title: "Cancelled Manga", + cover: { + raw: { url: null }, + x150: { x1: null, x2: null, x3: null }, + x250: { x1: null, x2: null, x3: null }, + x350: { x1: null, x2: null, x3: null }, + }, + type: "manga", + status: "cancelled", + }; + + const result = mapSeriesMetadata(series); + + // "cancelled" from MangaBaka maps to "abandoned" in Codex + expect(result.status).toBe("abandoned"); + }); + + it("should detect language from country of origin", () => { + const series: MbSeries = { + id: 2, + state: "active", + title: "Korean Manhwa", + native_title: "한국 만화", + cover: { + raw: { url: null }, + x150: { x1: null, x2: null, x3: null }, + x250: { x1: null, x2: null, x3: null }, + x350: { x1: null, x2: null, x3: null }, + }, + type: "manhwa", + status: "releasing", + country_of_origin: "kr", + }; + + const result = mapSeriesMetadata(series); + + expect(result.alternateTitles).toContainEqual({ + title: "한국 만화", + language: "ko", + titleType: "native", + }); + expect(result.readingDirection).toBe("ltr"); // Manhwa is left-to-right + }); + }); +}); diff --git a/plugins/metadata-mangabaka/src/mappers.ts b/plugins/metadata-mangabaka/src/mappers.ts new file mode 100644 index 00000000..2a7170ec --- /dev/null +++ b/plugins/metadata-mangabaka/src/mappers.ts @@ -0,0 +1,315 @@ +/** + * Mappers to convert MangaBaka API responses to Codex plugin protocol types + */ + +import type { + AlternateTitle, + ExternalLink, + ExternalRating, + PluginSeriesMetadata, + ReadingDirection, + SearchResult, + SeriesStatus, +} from "@codex/plugin-sdk"; +import type { MbContentRating, MbSeries, MbSeriesType, MbStatus } from "./types.js"; + +/** + * Map MangaBaka status to protocol SeriesStatus + * MangaBaka uses: cancelled, completed, hiatus, releasing, unknown, upcoming + * Codex uses: ongoing, ended, hiatus, abandoned, unknown + */ +function mapStatus(mbStatus: MbStatus): SeriesStatus { + switch (mbStatus) { + case "completed": + return "ended"; + case "releasing": + case "upcoming": + return "ongoing"; + case "hiatus": + return "hiatus"; + case "cancelled": + return "abandoned"; + default: + return "unknown"; + } +} + +/** + * Format genre from snake_case to Title Case + */ +function formatGenre(genre: string): string { + return genre + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +/** + * Detect language code from country of origin + */ +function detectLanguageFromCountry(country: string | null | undefined): string | undefined { + if (!country) return undefined; + + const countryLower = country.toLowerCase(); + if (countryLower === "jp" || countryLower === "japan") return "ja"; + if (countryLower === "kr" || countryLower === "korea" || countryLower === "south korea") + return "ko"; + if (countryLower === "cn" || countryLower === "china") return "zh"; + if (countryLower === "tw" || countryLower === "taiwan") return "zh-TW"; + + return undefined; +} + +/** + * Map MangaBaka content rating to numeric age rating + */ +function mapContentRating(rating: MbContentRating | null | undefined): number | undefined { + if (!rating) return undefined; + + switch (rating) { + case "safe": + return 0; // All ages + case "suggestive": + return 13; // Teen + case "erotica": + return 16; // Mature + case "pornographic": + return 18; // Adults only + default: + return undefined; + } +} + +/** + * Extract rating value from either a number or an object with bayesian/average + */ +function extractRating( + rating: number | { bayesian?: number | null; average?: number | null } | null | undefined, +): number | undefined { + if (rating == null) return undefined; + if (typeof rating === "number") return rating; + return rating.bayesian ?? rating.average ?? undefined; +} + +/** + * Infer reading direction from series type and country + */ +function inferReadingDirection( + seriesType: MbSeriesType, + country: string | null | undefined, +): ReadingDirection | undefined { + // Manhwa (Korean) and Manhua (Chinese) are typically left-to-right + if (seriesType === "manhwa" || seriesType === "manhua") { + return "ltr"; + } + + // Manga (Japanese) is typically right-to-left + if (seriesType === "manga") { + return "rtl"; + } + + // OEL (Original English Language) is left-to-right + if (seriesType === "oel") { + return "ltr"; + } + + // Fall back to country-based detection + if (country) { + const countryLower = country.toLowerCase(); + if (countryLower === "jp" || countryLower === "japan") return "rtl"; + if (countryLower === "kr" || countryLower === "korea" || countryLower === "south korea") + return "ltr"; + if (countryLower === "cn" || countryLower === "china") return "ltr"; + if (countryLower === "tw" || countryLower === "taiwan") return "ltr"; + } + + return undefined; +} + +/** + * Map a MangaBaka series to a protocol SearchResult + */ +export function mapSearchResult(series: MbSeries): SearchResult { + // Get cover URL - prefer x250 for search results + const coverUrl = series.cover?.x250?.x1 ?? series.cover?.raw?.url ?? undefined; + + // Build alternate titles array + const alternateTitles: string[] = []; + if (series.native_title && series.native_title !== series.title) { + alternateTitles.push(series.native_title); + } + if (series.romanized_title && series.romanized_title !== series.title) { + alternateTitles.push(series.romanized_title); + } + + // Note: relevanceScore is omitted - the API already returns results in relevance order + return { + externalId: String(series.id), + title: series.title, + alternateTitles, + year: series.year ?? undefined, + coverUrl: coverUrl ?? undefined, + preview: { + status: mapStatus(series.status), + genres: (series.genres ?? []).slice(0, 3).map(formatGenre), + rating: extractRating(series.rating), + description: series.description?.slice(0, 200) ?? undefined, + }, + }; +} + +/** + * Map full series response to protocol PluginSeriesMetadata + */ +export function mapSeriesMetadata(series: MbSeries): PluginSeriesMetadata { + // Build alternate titles array with language info + const alternateTitles: AlternateTitle[] = []; + + // Add native title + if (series.native_title && series.native_title !== series.title) { + alternateTitles.push({ + title: series.native_title, + language: detectLanguageFromCountry(series.country_of_origin), + titleType: "native", + }); + } + + // Add romanized title + if (series.romanized_title && series.romanized_title !== series.title) { + alternateTitles.push({ + title: series.romanized_title, + language: "en", + titleType: "romaji", + }); + } + + // Add secondary titles from all languages + if (series.secondary_titles) { + for (const [langCode, titleList] of Object.entries(series.secondary_titles)) { + if (titleList) { + for (const titleEntry of titleList) { + if (titleEntry.title !== series.title) { + alternateTitles.push({ + title: titleEntry.title, + language: langCode, + }); + } + } + } + } + } + + // Extract authors and artists as string arrays + const authors = series.authors ?? []; + const artists = series.artists ?? []; + + // Format genres + const genres = (series.genres ?? []).map(formatGenre); + + // Get cover URL - prefer raw for full metadata + const coverUrl = series.cover?.raw?.url ?? series.cover?.x350?.x1 ?? undefined; + + // Build external links from sources + // Always include MangaBaka link first + const externalLinks: ExternalLink[] = [ + { + url: `https://mangabaka.org/${series.id}`, + label: "MangaBaka", + linkType: "provider", + }, + ]; + + // Source configuration: display name, rating key, and URL pattern + // URL pattern uses {id} as placeholder for the source ID + const sourceConfig: Record = { + anilist: { + label: "AniList", + ratingKey: "anilist", + urlPattern: "https://anilist.co/manga/{id}", + }, + my_anime_list: { + label: "MyAnimeList", + ratingKey: "myanimelist", + urlPattern: "https://myanimelist.net/manga/{id}", + }, + mangadex: { + label: "MangaDex", + ratingKey: "mangadex", + urlPattern: "https://mangadex.org/title/{id}", + }, + manga_updates: { + label: "MangaUpdates", + ratingKey: "mangaupdates", + urlPattern: "https://www.mangaupdates.com/series/{id}", + }, + kitsu: { label: "Kitsu", ratingKey: "kitsu", urlPattern: "https://kitsu.app/manga/{id}" }, + anime_planet: { + label: "Anime-Planet", + ratingKey: "animeplanet", + urlPattern: "https://www.anime-planet.com/manga/{id}", + }, + anime_news_network: { label: "Anime News Network", ratingKey: "animenewsnetwork" }, + shikimori: { + label: "Shikimori", + ratingKey: "shikimori", + urlPattern: "https://shikimori.one/mangas/{id}", + }, + }; + + // Build external links and ratings from sources in a single pass + const externalRatings: ExternalRating[] = []; + + if (series.source) { + for (const [key, info] of Object.entries(series.source)) { + if (!info) continue; + + const config = sourceConfig[key]; + // Use config if available, otherwise generate defaults from key + const ratingKey = config?.ratingKey ?? key.replace(/_/g, ""); + + // Add external link if source has an ID and URL pattern + if (info.id != null && config?.urlPattern) { + externalLinks.push({ + url: config.urlPattern.replace("{id}", String(info.id)), + label: config.label, + linkType: "provider", + }); + } + + // Add external rating if source has a normalized rating + if (info.rating_normalized != null) { + externalRatings.push({ score: info.rating_normalized, source: ratingKey }); + } + } + } + + // Get publisher name (pick first one if available) + const publisher = series.publishers?.[0]?.name ?? undefined; + + return { + externalId: String(series.id), + externalUrl: `https://mangabaka.org/${series.id}`, + title: series.title, + alternateTitles, + summary: series.description ?? undefined, + status: mapStatus(series.status), + year: series.year ?? undefined, + // Extended metadata + publisher, + totalBookCount: series.final_volume ? Number.parseInt(series.final_volume, 10) : undefined, + ageRating: mapContentRating(series.content_rating), + readingDirection: inferReadingDirection(series.type, series.country_of_origin), + // Taxonomy + genres, + tags: series.tags ?? [], + authors, + artists, + coverUrl: coverUrl ?? undefined, + rating: (() => { + const r = extractRating(series.rating); + return r != null ? { score: r, source: "mangabaka" } : undefined; + })(), + externalRatings: externalRatings.length > 0 ? externalRatings : undefined, + externalLinks, + }; +} diff --git a/plugins/metadata-mangabaka/src/types.ts b/plugins/metadata-mangabaka/src/types.ts new file mode 100644 index 00000000..c23a3d8d --- /dev/null +++ b/plugins/metadata-mangabaka/src/types.ts @@ -0,0 +1,222 @@ +/** + * MangaBaka API response types + * Based on: https://api.mangabaka.dev/ + */ + +/** + * Standard API response wrapper + */ +export interface MbApiResponse { + status: number; + data: T; + pagination?: MbPagination; +} + +export interface MbPagination { + page: number; + per_page: number; + total: number; + total_pages: number; +} + +/** + * Series type enum + */ +export type MbSeriesType = "manga" | "novel" | "manhwa" | "manhua" | "oel" | "other"; + +/** + * Publication status enum + */ +export type MbStatus = "cancelled" | "completed" | "hiatus" | "releasing" | "unknown" | "upcoming"; + +/** + * Content rating enum + */ +export type MbContentRating = "safe" | "suggestive" | "erotica" | "pornographic"; + +/** + * Genre enum + */ +export type MbGenre = + | "action" + | "adult" + | "adventure" + | "avant_garde" + | "award_winning" + | "boys_love" + | "comedy" + | "doujinshi" + | "drama" + | "ecchi" + | "erotica" + | "fantasy" + | "gender_bender" + | "girls_love" + | "gourmet" + | "harem" + | "hentai" + | "historical" + | "horror" + | "josei" + | "lolicon" + | "mahou_shoujo" + | "martial_arts" + | "mature" + | "mecha" + | "music" + | "mystery" + | "psychological" + | "romance" + | "school_life" + | "sci-fi" + | "seinen" + | "shotacon" + | "shoujo" + | "shoujo_ai" + | "shounen" + | "shounen_ai" + | "slice_of_life" + | "smut" + | "sports" + | "supernatural" + | "suspense" + | "thriller" + | "tragedy" + | "yaoi" + | "yuri"; + +/** + * Series state + */ +export type MbSeriesState = "active" | "merged" | "deleted"; + +/** + * Publisher information + */ +export interface MbPublisher { + name: string; + type: string; + note?: string | null; +} + +/** + * Cover image structure + */ +export interface MbCover { + raw: { + url: string | null; + size?: number | null; + height?: number | null; + width?: number | null; + blurhash?: string | null; + thumbhash?: string | null; + format?: string | null; + }; + x150: MbScaledImage; + x250: MbScaledImage; + x350: MbScaledImage; +} + +export interface MbScaledImage { + x1: string | null; + x2: string | null; + x3: string | null; +} + +/** + * Secondary title entry + */ +export interface MbSecondaryTitle { + type: "alternative" | "native" | "official" | "unofficial"; + title: string; + note?: string | null; +} + +/** + * Secondary titles by language code + */ +export interface MbSecondaryTitles { + [languageCode: string]: MbSecondaryTitle[] | null; +} + +/** + * Source information (e.g., anilist, mal, etc.) + */ +export interface MbSourceInfo { + id: number | string | null; + rating?: number | null; + rating_normalized?: number | null; +} + +/** + * Series relationships + */ +export interface MbRelationships { + main_story?: number[]; + adaptation?: number[]; + prequel?: number[]; + sequel?: number[]; + side_story?: number[]; + spin_off?: number[]; + alternative?: number[]; + other?: number[]; +} + +/** + * Series data from search or get endpoints + */ +export interface MbSeries { + id: number; + state: MbSeriesState; + merged_with?: number | null; + title: string; + native_title?: string | null; + romanized_title?: string | null; + secondary_titles?: MbSecondaryTitles | null; + cover: MbCover; + authors?: string[] | null; + artists?: string[] | null; + publishers?: MbPublisher[] | null; + description?: string | null; + year?: number | null; + final_volume?: string | null; + status: MbStatus; + is_licensed?: boolean; + has_anime?: boolean; + type: MbSeriesType; + country_of_origin?: string | null; + content_rating?: MbContentRating | null; + genres?: MbGenre[] | null; + tags?: string[] | null; + relationships?: MbRelationships | null; + source?: { + anilist?: MbSourceInfo; + my_anime_list?: MbSourceInfo; + mangadex?: MbSourceInfo; + manga_updates?: MbSourceInfo; + kitsu?: MbSourceInfo; + anime_planet?: MbSourceInfo; + anime_news_network?: MbSourceInfo; + shikimori?: MbSourceInfo; + [key: string]: MbSourceInfo | undefined; + }; + rating?: + | number + | { + average?: number | null; + bayesian?: number | null; + distribution?: Record | null; + } + | null; + last_updated_at?: string | null; +} + +/** + * Search response - array of series + */ +export type MbSearchResponse = MbApiResponse; + +/** + * Get series response - single series + */ +export type MbGetSeriesResponse = MbApiResponse; diff --git a/plugins/metadata-mangabaka/tsconfig.json b/plugins/metadata-mangabaka/tsconfig.json new file mode 100644 index 00000000..ef1ca5f9 --- /dev/null +++ b/plugins/metadata-mangabaka/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/plugins/metadata-mangabaka/vitest.config.ts b/plugins/metadata-mangabaka/vitest.config.ts new file mode 100644 index 00000000..ae847ff6 --- /dev/null +++ b/plugins/metadata-mangabaka/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/plugins/sdk-typescript/.gitignore b/plugins/sdk-typescript/.gitignore new file mode 100644 index 00000000..e389b224 --- /dev/null +++ b/plugins/sdk-typescript/.gitignore @@ -0,0 +1,11 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# TypeScript +*.tsbuildinfo + +# OS files +.DS_Store diff --git a/plugins/sdk-typescript/README.md b/plugins/sdk-typescript/README.md new file mode 100644 index 00000000..ff850949 --- /dev/null +++ b/plugins/sdk-typescript/README.md @@ -0,0 +1,260 @@ +# @codex/plugin-sdk + +Official SDK for building Codex plugins. Provides type-safe interfaces, utilities, and a server framework for communicating with Codex via JSON-RPC over stdio. + +## Installation + +```bash +npm install @codex/plugin-sdk +``` + +## Quick Start + +```typescript +import { + createMetadataPlugin, + type MetadataContentType, + type MetadataProvider, + type PluginManifest, +} from "@codex/plugin-sdk"; + +// Define your plugin manifest +const manifest = { + name: "metadata-my-plugin", + displayName: "My Plugin", + version: "1.0.0", + description: "A custom metadata provider", + author: "Your Name", + protocolVersion: "1.0", + capabilities: { + metadataProvider: ["series"] as MetadataContentType[], + }, +} as const satisfies PluginManifest & { + capabilities: { metadataProvider: MetadataContentType[] }; +}; + +// Implement the MetadataProvider interface +const provider: MetadataProvider = { + async search(params) { + // Search your data source + return { + results: [ + { + externalId: "123", + title: "Example Series", + alternateTitles: [], + relevanceScore: 0.95, + preview: { + status: "ongoing", + genres: ["Action", "Adventure"], + }, + }, + ], + }; + }, + + async get(params) { + // Fetch full metadata + return { + externalId: params.externalId, + externalUrl: `https://example.com/series/${params.externalId}`, + title: "Example Series", + alternateTitles: [], + summary: "An exciting series...", + status: "ongoing", + year: 2024, + genres: ["Action", "Adventure"], + tags: [], + authors: ["Author Name"], + artists: [], + externalLinks: [], + }; + }, + + // Optional: implement match for auto-matching during library scans + async match(params) { + const result = await this.search({ + query: params.title, + contentType: params.contentType, + }); + + return { + match: result.results[0] ?? null, + confidence: result.results[0] ? 0.8 : 0, + }; + }, +}; + +// Start the plugin +createMetadataPlugin({ manifest, provider }); +``` + +## Features + +- **Type-safe**: Full TypeScript support with interface contracts +- **Protocol compliance**: Types match the Codex JSON-RPC protocol exactly +- **Simple API**: Implement the `MetadataProvider` interface, SDK handles the rest +- **Error handling**: Built-in error classes that map to JSON-RPC errors +- **Logging**: Safe logging to stderr (stdout is reserved for protocol) + +## Concepts + +### Capabilities + +Plugins declare capabilities in their manifest. Each capability has a corresponding interface: + +| Capability | Interface | Description | +|------------|-----------|-------------| +| `metadataProvider` | `MetadataProvider` | Search and fetch metadata for content | + +The `metadataProvider` capability is an array of content types your plugin supports: + +```typescript +capabilities: { + metadataProvider: ["series"] as MetadataContentType[], +} +``` + +Supported content types: `"series"` (future: `"book"`) + +### Protocol Types + +The SDK provides TypeScript types that exactly match the Codex JSON-RPC protocol: + +- `MetadataSearchParams` / `MetadataSearchResponse` - Search parameters and results +- `MetadataGetParams` / `PluginSeriesMetadata` - Get full metadata +- `MetadataMatchParams` / `MetadataMatchResponse` - Auto-match content +- `SearchResult` - Individual search result with `relevanceScore` + +### Relevance Score + +Search results must include a `relevanceScore` between 0.0 and 1.0: + +- `1.0` = Perfect match +- `0.7-0.9` = Good match +- `0.5-0.7` = Partial match +- `< 0.5` = Weak match + +## API Reference + +### `createMetadataPlugin(options)` + +Creates and starts a metadata provider plugin. + +```typescript +interface MetadataPluginOptions { + manifest: PluginManifest & { capabilities: { metadataProvider: MetadataContentType[] } }; + provider: MetadataProvider; + onInitialize?: (params: InitializeParams) => void | Promise; + logLevel?: "debug" | "info" | "warn" | "error"; +} +``` + +### `MetadataProvider` + +Interface for metadata provider plugins: + +```typescript +interface MetadataProvider { + search(params: MetadataSearchParams): Promise; + get(params: MetadataGetParams): Promise; + match?(params: MetadataMatchParams): Promise; +} +``` + +### Error Handling + +Use built-in error classes for proper error responses: + +```typescript +import { NotFoundError, RateLimitError, AuthError, ApiError } from "@codex/plugin-sdk"; + +// In your provider: +async get(params) { + const data = await fetchFromApi(params.externalId); + + if (!data) { + throw new NotFoundError(`Series ${params.externalId} not found`); + } + + return data; +} +``` + +### Logging + +Plugins should log to stderr (stdout is reserved for JSON-RPC): + +```typescript +import { createLogger } from "@codex/plugin-sdk"; + +const logger = createLogger({ name: "my-plugin", level: "debug" }); +logger.info("Plugin started"); +logger.debug("Processing request", { query: "..." }); +logger.error("Failed to fetch", error); +``` + +## Credentials + +Plugins can receive credentials (API keys, tokens) via the `onInitialize` callback: + +```typescript +let apiKey: string | undefined; + +createMetadataPlugin({ + manifest, + provider, + onInitialize(params) { + apiKey = params.credentials?.api_key; + }, +}); +``` + +Credentials are configured by admins in Codex and delivered securely to the plugin. + +## Protocol + +Plugins communicate with Codex via JSON-RPC 2.0 over stdio: + +- **stdin**: Receives JSON-RPC requests (one per line) +- **stdout**: Sends JSON-RPC responses (one per line) +- **stderr**: Logging output (visible in Codex logs) + +## Building Your Plugin + +1. Create a new npm package +2. Install the SDK: `npm install @codex/plugin-sdk` +3. Implement your provider +4. Bundle with esbuild (include shebang for npx support): + +```json +{ + "name": "@your-org/plugin-metadata-example", + "version": "1.0.0", + "main": "dist/index.js", + "bin": "dist/index.js", + "type": "module", + "files": ["dist", "README.md"], + "engines": { + "node": ">=22.0.0" + }, + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --sourcemap --banner:js='#!/usr/bin/env node'", + "prepublishOnly": "npm run lint && npm run build" + } +} +``` + +**Key fields for npx support:** +- `bin`: Points to the entry file, tells npx what to execute +- `--banner:js='#!/usr/bin/env node'`: Adds shebang so the file is directly executable +- `files`: Only publish dist and README to npm + +## Example Plugins + +- [`@codex/plugin-metadata-echo`](https://github.com/AshDevFr/codex/tree/main/plugins/metadata-echo) - Test metadata plugin that echoes back queries +- [`@codex/plugin-metadata-mangabaka`](https://github.com/AshDevFr/codex/tree/main/plugins/metadata-mangabaka) - MangaBaka metadata provider + +## License + +MIT diff --git a/plugins/sdk-typescript/package-lock.json b/plugins/sdk-typescript/package-lock.json new file mode 100644 index 00000000..b2d44f17 --- /dev/null +++ b/plugins/sdk-typescript/package-lock.json @@ -0,0 +1,1786 @@ +{ + "name": "@codex/plugin-sdk", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@codex/plugin-sdk", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@biomejs/biome": "^2.3.11", + "@types/node": "^22.0.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.11.tgz", + "integrity": "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.3.11", + "@biomejs/cli-darwin-x64": "2.3.11", + "@biomejs/cli-linux-arm64": "2.3.11", + "@biomejs/cli-linux-arm64-musl": "2.3.11", + "@biomejs/cli-linux-x64": "2.3.11", + "@biomejs/cli-linux-x64-musl": "2.3.11", + "@biomejs/cli-win32-arm64": "2.3.11", + "@biomejs/cli-win32-x64": "2.3.11" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.11.tgz", + "integrity": "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.11.tgz", + "integrity": "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.11.tgz", + "integrity": "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.11.tgz", + "integrity": "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.11.tgz", + "integrity": "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.11.tgz", + "integrity": "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.11.tgz", + "integrity": "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.11.tgz", + "integrity": "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz", + "integrity": "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz", + "integrity": "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz", + "integrity": "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz", + "integrity": "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz", + "integrity": "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz", + "integrity": "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz", + "integrity": "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz", + "integrity": "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz", + "integrity": "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz", + "integrity": "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz", + "integrity": "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz", + "integrity": "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz", + "integrity": "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz", + "integrity": "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz", + "integrity": "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz", + "integrity": "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz", + "integrity": "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz", + "integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz", + "integrity": "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz", + "integrity": "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz", + "integrity": "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz", + "integrity": "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz", + "integrity": "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz", + "integrity": "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz", + "integrity": "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", + "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz", + "integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.0", + "@rollup/rollup-android-arm64": "4.57.0", + "@rollup/rollup-darwin-arm64": "4.57.0", + "@rollup/rollup-darwin-x64": "4.57.0", + "@rollup/rollup-freebsd-arm64": "4.57.0", + "@rollup/rollup-freebsd-x64": "4.57.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", + "@rollup/rollup-linux-arm-musleabihf": "4.57.0", + "@rollup/rollup-linux-arm64-gnu": "4.57.0", + "@rollup/rollup-linux-arm64-musl": "4.57.0", + "@rollup/rollup-linux-loong64-gnu": "4.57.0", + "@rollup/rollup-linux-loong64-musl": "4.57.0", + "@rollup/rollup-linux-ppc64-gnu": "4.57.0", + "@rollup/rollup-linux-ppc64-musl": "4.57.0", + "@rollup/rollup-linux-riscv64-gnu": "4.57.0", + "@rollup/rollup-linux-riscv64-musl": "4.57.0", + "@rollup/rollup-linux-s390x-gnu": "4.57.0", + "@rollup/rollup-linux-x64-gnu": "4.57.0", + "@rollup/rollup-linux-x64-musl": "4.57.0", + "@rollup/rollup-openbsd-x64": "4.57.0", + "@rollup/rollup-openharmony-arm64": "4.57.0", + "@rollup/rollup-win32-arm64-msvc": "4.57.0", + "@rollup/rollup-win32-ia32-msvc": "4.57.0", + "@rollup/rollup-win32-x64-gnu": "4.57.0", + "@rollup/rollup-win32-x64-msvc": "4.57.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/plugins/sdk-typescript/package.json b/plugins/sdk-typescript/package.json new file mode 100644 index 00000000..1139c478 --- /dev/null +++ b/plugins/sdk-typescript/package.json @@ -0,0 +1,55 @@ +{ + "name": "@codex/plugin-sdk", + "version": "1.0.0", + "description": "SDK for building Codex plugins", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "clean": "rm -rf dist", + "prepublishOnly": "npm run lint && npm run build", + "test": "vitest run", + "test:watch": "vitest", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write .", + "check": "biome check . && tsc --noEmit" + }, + "keywords": [ + "codex", + "plugin", + "sdk", + "metadata" + ], + "author": "Codex", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/AshDevFr/codex.git", + "directory": "plugins/sdk-typescript" + }, + "engines": { + "node": ">=22.0.0" + }, + "codex": { + "protocolVersion": "1.0" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.11", + "@types/node": "^22.0.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/plugins/sdk-typescript/src/errors.test.ts b/plugins/sdk-typescript/src/errors.test.ts new file mode 100644 index 00000000..75f30f0a --- /dev/null +++ b/plugins/sdk-typescript/src/errors.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { ApiError, AuthError, ConfigError, NotFoundError, RateLimitError } from "./errors.js"; +import { PLUGIN_ERROR_CODES } from "./types/rpc.js"; + +describe("PluginError classes", () => { + describe("RateLimitError", () => { + it("should have correct code and retry time", () => { + const error = new RateLimitError(60); + + expect(error.code).toBe(PLUGIN_ERROR_CODES.RATE_LIMITED); + expect(error.retryAfterSeconds).toBe(60); + expect(error.message).toBe("Rate limited, retry after 60s"); + }); + + it("should accept custom message", () => { + const error = new RateLimitError(30, "Too many requests"); + + expect(error.message).toBe("Too many requests"); + expect(error.retryAfterSeconds).toBe(30); + }); + + it("should convert to JSON-RPC error", () => { + const error = new RateLimitError(60); + const rpcError = error.toJsonRpcError(); + + expect(rpcError).toEqual({ + code: PLUGIN_ERROR_CODES.RATE_LIMITED, + message: "Rate limited, retry after 60s", + data: { retryAfterSeconds: 60 }, + }); + }); + }); + + describe("NotFoundError", () => { + it("should have correct code", () => { + const error = new NotFoundError("Series 123 not found"); + + expect(error.code).toBe(PLUGIN_ERROR_CODES.NOT_FOUND); + expect(error.message).toBe("Series 123 not found"); + }); + + it("should convert to JSON-RPC error", () => { + const error = new NotFoundError("Not found"); + const rpcError = error.toJsonRpcError(); + + expect(rpcError).toEqual({ + code: PLUGIN_ERROR_CODES.NOT_FOUND, + message: "Not found", + data: undefined, + }); + }); + }); + + describe("AuthError", () => { + it("should have correct code and default message", () => { + const error = new AuthError(); + + expect(error.code).toBe(PLUGIN_ERROR_CODES.AUTH_FAILED); + expect(error.message).toBe("Authentication failed"); + }); + + it("should accept custom message", () => { + const error = new AuthError("Invalid API key"); + + expect(error.message).toBe("Invalid API key"); + }); + }); + + describe("ApiError", () => { + it("should have correct code and status", () => { + const error = new ApiError("Server error", 500); + + expect(error.code).toBe(PLUGIN_ERROR_CODES.API_ERROR); + expect(error.statusCode).toBe(500); + expect(error.message).toBe("Server error"); + }); + + it("should convert to JSON-RPC error with status", () => { + const error = new ApiError("Bad gateway", 502); + const rpcError = error.toJsonRpcError(); + + expect(rpcError).toEqual({ + code: PLUGIN_ERROR_CODES.API_ERROR, + message: "Bad gateway", + data: { statusCode: 502 }, + }); + }); + }); + + describe("ConfigError", () => { + it("should have correct code", () => { + const error = new ConfigError("Missing API key"); + + expect(error.code).toBe(PLUGIN_ERROR_CODES.CONFIG_ERROR); + expect(error.message).toBe("Missing API key"); + }); + }); +}); diff --git a/plugins/sdk-typescript/src/errors.ts b/plugins/sdk-typescript/src/errors.ts new file mode 100644 index 00000000..0b00afa0 --- /dev/null +++ b/plugins/sdk-typescript/src/errors.ts @@ -0,0 +1,84 @@ +/** + * Plugin error classes for structured error handling + */ + +import { type JsonRpcError, PLUGIN_ERROR_CODES } from "./types/rpc.js"; + +/** + * Base class for plugin errors that map to JSON-RPC errors + */ +export abstract class PluginError extends Error { + abstract readonly code: number; + readonly data?: unknown; + + constructor(message: string, data?: unknown) { + super(message); + this.name = this.constructor.name; + this.data = data; + } + + /** + * Convert to JSON-RPC error format + */ + toJsonRpcError(): JsonRpcError { + return { + code: this.code, + message: this.message, + data: this.data, + }; + } +} + +/** + * Thrown when rate limited by an external API + */ +export class RateLimitError extends PluginError { + readonly code = PLUGIN_ERROR_CODES.RATE_LIMITED; + /** Seconds to wait before retrying */ + readonly retryAfterSeconds: number; + + constructor(retryAfterSeconds: number, message?: string) { + super(message ?? `Rate limited, retry after ${retryAfterSeconds}s`, { + retryAfterSeconds, + }); + this.retryAfterSeconds = retryAfterSeconds; + } +} + +/** + * Thrown when a requested resource is not found + */ +export class NotFoundError extends PluginError { + readonly code = PLUGIN_ERROR_CODES.NOT_FOUND; +} + +/** + * Thrown when authentication fails (invalid credentials) + */ +export class AuthError extends PluginError { + readonly code = PLUGIN_ERROR_CODES.AUTH_FAILED; + + constructor(message?: string) { + super(message ?? "Authentication failed"); + } +} + +/** + * Thrown when an external API returns an error + */ +export class ApiError extends PluginError { + readonly code = PLUGIN_ERROR_CODES.API_ERROR; + readonly statusCode: number | undefined; + + constructor(message: string, statusCode?: number) { + super(message, statusCode !== undefined ? { statusCode } : undefined); + this.statusCode = statusCode; + } +} + +/** + * Thrown when the plugin is misconfigured + */ +export class ConfigError extends PluginError { + readonly code = PLUGIN_ERROR_CODES.CONFIG_ERROR; +} diff --git a/plugins/sdk-typescript/src/index.ts b/plugins/sdk-typescript/src/index.ts new file mode 100644 index 00000000..74c23ca3 --- /dev/null +++ b/plugins/sdk-typescript/src/index.ts @@ -0,0 +1,80 @@ +/** + * @codex/plugin-sdk + * + * SDK for building Codex plugins. Provides types, utilities, and a server + * framework for communicating with Codex via JSON-RPC over stdio. + * + * @example + * ```typescript + * import { + * createMetadataPlugin, + * type MetadataProvider, + * type PluginManifest, + * } from "@codex/plugin-sdk"; + * + * const manifest: PluginManifest = { + * name: "my-plugin", + * displayName: "My Plugin", + * version: "1.0.0", + * description: "A custom metadata plugin", + * author: "Your Name", + * protocolVersion: "1.0", + * capabilities: { metadataProvider: ["series"] }, + * }; + * + * const provider: MetadataProvider = { + * async search(params) { + * return { + * results: [{ + * externalId: "123", + * title: "Example", + * alternateTitles: [], + * relevanceScore: 0.95, + * }], + * }; + * }, + * async get(params) { + * return { + * externalId: params.externalId, + * externalUrl: "https://example.com/123", + * alternateTitles: [], + * genres: [], + * tags: [], + * authors: [], + * artists: [], + * externalLinks: [], + * }; + * }, + * }; + * + * createMetadataPlugin({ manifest, provider }); + * ``` + * + * @packageDocumentation + */ + +// Errors +export { + ApiError, + AuthError, + ConfigError, + NotFoundError, + PluginError, + RateLimitError, +} from "./errors.js"; + +// Logger +export { createLogger, Logger, type LoggerOptions, type LogLevel } from "./logger.js"; +// Server +export { + // Primary exports + createMetadataPlugin, + // Deprecated aliases + createSeriesMetadataPlugin, + type InitializeParams, + type MetadataPluginOptions, + type SeriesMetadataPluginOptions, +} from "./server.js"; + +// Types +export * from "./types/index.js"; diff --git a/plugins/sdk-typescript/src/logger.ts b/plugins/sdk-typescript/src/logger.ts new file mode 100644 index 00000000..d835f279 --- /dev/null +++ b/plugins/sdk-typescript/src/logger.ts @@ -0,0 +1,100 @@ +/** + * Logging utilities for plugins + * + * IMPORTANT: Plugins must ONLY write to stderr for logging. + * stdout is reserved for JSON-RPC communication. + */ + +export type LogLevel = "debug" | "info" | "warn" | "error"; + +const LOG_LEVELS: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +export interface LoggerOptions { + /** Plugin name to prefix log messages */ + name: string; + /** Minimum log level (default: "info") */ + level?: LogLevel; + /** Whether to include timestamps (default: true) */ + timestamps?: boolean; +} + +/** + * Logger that writes to stderr (safe for plugins) + */ +export class Logger { + private readonly name: string; + private readonly minLevel: number; + private readonly timestamps: boolean; + + constructor(options: LoggerOptions) { + this.name = options.name; + this.minLevel = LOG_LEVELS[options.level ?? "info"]; + this.timestamps = options.timestamps ?? true; + } + + private shouldLog(level: LogLevel): boolean { + return LOG_LEVELS[level] >= this.minLevel; + } + + private format(level: LogLevel, message: string, data?: unknown): string { + const parts: string[] = []; + + if (this.timestamps) { + parts.push(new Date().toISOString()); + } + + parts.push(`[${level.toUpperCase()}]`); + parts.push(`[${this.name}]`); + parts.push(message); + + if (data !== undefined) { + if (data instanceof Error) { + parts.push(`- ${data.message}`); + if (data.stack) { + parts.push(`\n${data.stack}`); + } + } else if (typeof data === "object") { + parts.push(`- ${JSON.stringify(data)}`); + } else { + parts.push(`- ${String(data)}`); + } + } + + return parts.join(" "); + } + + private log(level: LogLevel, message: string, data?: unknown): void { + if (this.shouldLog(level)) { + // Write to stderr (not stdout!) - stdout is for JSON-RPC only + process.stderr.write(`${this.format(level, message, data)}\n`); + } + } + + debug(message: string, data?: unknown): void { + this.log("debug", message, data); + } + + info(message: string, data?: unknown): void { + this.log("info", message, data); + } + + warn(message: string, data?: unknown): void { + this.log("warn", message, data); + } + + error(message: string, data?: unknown): void { + this.log("error", message, data); + } +} + +/** + * Create a logger for a plugin + */ +export function createLogger(options: LoggerOptions): Logger { + return new Logger(options); +} diff --git a/plugins/sdk-typescript/src/server.test.ts b/plugins/sdk-typescript/src/server.test.ts new file mode 100644 index 00000000..2b233c0b --- /dev/null +++ b/plugins/sdk-typescript/src/server.test.ts @@ -0,0 +1,214 @@ +/** + * Tests for server.ts - JSON-RPC server implementation + * + * These tests cover: + * - Parameter validation for search, get, and match methods + * - Error handling for invalid requests + * - Request handling flow + */ + +import { describe, expect, it } from "vitest"; +import { JSON_RPC_ERROR_CODES } from "./types/rpc.js"; + +// ============================================================================= +// Test Helpers - Re-implement validation functions for testing +// (These mirror the internal functions in server.ts) +// ============================================================================= + +interface ValidationError { + field: string; + message: string; +} + +function validateStringFields(params: unknown, fields: string[]): ValidationError | null { + if (params === null || params === undefined) { + return { field: "params", message: "params is required" }; + } + if (typeof params !== "object") { + return { field: "params", message: "params must be an object" }; + } + + const obj = params as Record; + for (const field of fields) { + const value = obj[field]; + if (value === undefined || value === null) { + return { field, message: `${field} is required` }; + } + if (typeof value !== "string") { + return { field, message: `${field} must be a string` }; + } + if (value.trim() === "") { + return { field, message: `${field} cannot be empty` }; + } + } + + return null; +} + +function validateSearchParams(params: unknown): ValidationError | null { + return validateStringFields(params, ["query"]); +} + +function validateGetParams(params: unknown): ValidationError | null { + return validateStringFields(params, ["externalId"]); +} + +function validateMatchParams(params: unknown): ValidationError | null { + return validateStringFields(params, ["title"]); +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe("Parameter Validation", () => { + describe("validateStringFields", () => { + it("should return error when params is null", () => { + const result = validateStringFields(null, ["query"]); + expect(result).toEqual({ field: "params", message: "params is required" }); + }); + + it("should return error when params is undefined", () => { + const result = validateStringFields(undefined, ["query"]); + expect(result).toEqual({ field: "params", message: "params is required" }); + }); + + it("should return error when params is not an object", () => { + const result = validateStringFields("string", ["query"]); + expect(result).toEqual({ field: "params", message: "params must be an object" }); + }); + + it("should return error when params is a number", () => { + const result = validateStringFields(123, ["query"]); + expect(result).toEqual({ field: "params", message: "params must be an object" }); + }); + + it("should return error when required field is missing", () => { + const result = validateStringFields({ other: "value" }, ["query"]); + expect(result).toEqual({ field: "query", message: "query is required" }); + }); + + it("should return error when required field is null", () => { + const result = validateStringFields({ query: null }, ["query"]); + expect(result).toEqual({ field: "query", message: "query is required" }); + }); + + it("should return error when field is not a string", () => { + const result = validateStringFields({ query: 123 }, ["query"]); + expect(result).toEqual({ field: "query", message: "query must be a string" }); + }); + + it("should return error when field is an object", () => { + const result = validateStringFields({ query: {} }, ["query"]); + expect(result).toEqual({ field: "query", message: "query must be a string" }); + }); + + it("should return error when field is an empty string", () => { + const result = validateStringFields({ query: "" }, ["query"]); + expect(result).toEqual({ field: "query", message: "query cannot be empty" }); + }); + + it("should return error when field is whitespace only", () => { + const result = validateStringFields({ query: " " }, ["query"]); + expect(result).toEqual({ field: "query", message: "query cannot be empty" }); + }); + + it("should return null when validation passes", () => { + const result = validateStringFields({ query: "test" }, ["query"]); + expect(result).toBeNull(); + }); + + it("should validate multiple fields", () => { + const result = validateStringFields({ a: "x", b: "y" }, ["a", "b"]); + expect(result).toBeNull(); + }); + + it("should return error for first missing field in multi-field validation", () => { + const result = validateStringFields({ a: "x" }, ["a", "b"]); + expect(result).toEqual({ field: "b", message: "b is required" }); + }); + + it("should accept objects with extra fields", () => { + const result = validateStringFields({ query: "test", extra: "ignored" }, ["query"]); + expect(result).toBeNull(); + }); + }); + + describe("validateSearchParams", () => { + it("should require query field", () => { + const result = validateSearchParams({}); + expect(result).toEqual({ field: "query", message: "query is required" }); + }); + + it("should accept valid search params", () => { + const result = validateSearchParams({ query: "one piece", limit: 10 }); + expect(result).toBeNull(); + }); + + it("should reject empty query", () => { + const result = validateSearchParams({ query: "" }); + expect(result).toEqual({ field: "query", message: "query cannot be empty" }); + }); + }); + + describe("validateGetParams", () => { + it("should require externalId field", () => { + const result = validateGetParams({}); + expect(result).toEqual({ field: "externalId", message: "externalId is required" }); + }); + + it("should accept valid get params", () => { + const result = validateGetParams({ externalId: "12345" }); + expect(result).toBeNull(); + }); + + it("should reject empty externalId", () => { + const result = validateGetParams({ externalId: "" }); + expect(result).toEqual({ field: "externalId", message: "externalId cannot be empty" }); + }); + + it("should reject non-string externalId", () => { + const result = validateGetParams({ externalId: 12345 }); + expect(result).toEqual({ field: "externalId", message: "externalId must be a string" }); + }); + }); + + describe("validateMatchParams", () => { + it("should require title field", () => { + const result = validateMatchParams({}); + expect(result).toEqual({ field: "title", message: "title is required" }); + }); + + it("should accept valid match params", () => { + const result = validateMatchParams({ title: "Naruto", year: 2002 }); + expect(result).toBeNull(); + }); + + it("should reject empty title", () => { + const result = validateMatchParams({ title: "" }); + expect(result).toEqual({ field: "title", message: "title cannot be empty" }); + }); + }); +}); + +describe("JSON-RPC Error Codes", () => { + it("should have correct INVALID_PARAMS code", () => { + expect(JSON_RPC_ERROR_CODES.INVALID_PARAMS).toBe(-32602); + }); + + it("should have correct PARSE_ERROR code", () => { + expect(JSON_RPC_ERROR_CODES.PARSE_ERROR).toBe(-32700); + }); + + it("should have correct INVALID_REQUEST code", () => { + expect(JSON_RPC_ERROR_CODES.INVALID_REQUEST).toBe(-32600); + }); + + it("should have correct METHOD_NOT_FOUND code", () => { + expect(JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND).toBe(-32601); + }); + + it("should have correct INTERNAL_ERROR code", () => { + expect(JSON_RPC_ERROR_CODES.INTERNAL_ERROR).toBe(-32603); + }); +}); diff --git a/plugins/sdk-typescript/src/server.ts b/plugins/sdk-typescript/src/server.ts new file mode 100644 index 00000000..45f9f0da --- /dev/null +++ b/plugins/sdk-typescript/src/server.ts @@ -0,0 +1,409 @@ +/** + * Plugin server - handles JSON-RPC communication over stdio + */ + +import { createInterface } from "node:readline"; +import { PluginError } from "./errors.js"; +import { createLogger, type Logger } from "./logger.js"; +import type { MetadataContentType, MetadataProvider } from "./types/capabilities.js"; +import type { PluginManifest } from "./types/manifest.js"; +import type { + MetadataGetParams, + MetadataMatchParams, + MetadataSearchParams, +} from "./types/protocol.js"; +import { + JSON_RPC_ERROR_CODES, + type JsonRpcError, + type JsonRpcRequest, + type JsonRpcResponse, +} from "./types/rpc.js"; + +// ============================================================================= +// Parameter Validation +// ============================================================================= + +interface ValidationError { + field: string; + message: string; +} + +/** + * Validate that the required string fields are present and non-empty + */ +function validateStringFields(params: unknown, fields: string[]): ValidationError | null { + if (params === null || params === undefined) { + return { field: "params", message: "params is required" }; + } + if (typeof params !== "object") { + return { field: "params", message: "params must be an object" }; + } + + const obj = params as Record; + for (const field of fields) { + const value = obj[field]; + if (value === undefined || value === null) { + return { field, message: `${field} is required` }; + } + if (typeof value !== "string") { + return { field, message: `${field} must be a string` }; + } + if (value.trim() === "") { + return { field, message: `${field} cannot be empty` }; + } + } + + return null; +} + +/** + * Validate MetadataSearchParams + */ +function validateSearchParams(params: unknown): ValidationError | null { + return validateStringFields(params, ["query"]); +} + +/** + * Validate MetadataGetParams + */ +function validateGetParams(params: unknown): ValidationError | null { + return validateStringFields(params, ["externalId"]); +} + +/** + * Validate MetadataMatchParams + */ +function validateMatchParams(params: unknown): ValidationError | null { + return validateStringFields(params, ["title"]); +} + +/** + * Create an INVALID_PARAMS error response + */ +function invalidParamsError(id: string | number | null, error: ValidationError): JsonRpcResponse { + return { + jsonrpc: "2.0", + id, + error: { + code: JSON_RPC_ERROR_CODES.INVALID_PARAMS, + message: `Invalid params: ${error.message}`, + data: { field: error.field }, + } as JsonRpcError, + }; +} + +/** + * Initialize parameters received from Codex + */ +export interface InitializeParams { + /** Plugin configuration */ + config?: Record; + /** Plugin credentials (API keys, tokens, etc.) */ + credentials?: Record; +} + +/** + * Options for creating a metadata plugin + */ +export interface MetadataPluginOptions { + /** Plugin manifest - must have capabilities.metadataProvider with content types */ + manifest: PluginManifest & { + capabilities: { metadataProvider: MetadataContentType[] }; + }; + /** MetadataProvider implementation */ + provider: MetadataProvider; + /** Called when plugin receives initialize with credentials/config */ + onInitialize?: (params: InitializeParams) => void | Promise; + /** Log level (default: "info") */ + logLevel?: "debug" | "info" | "warn" | "error"; +} + +/** + * Create and run a metadata provider plugin + * + * Creates a plugin server that handles JSON-RPC communication over stdio. + * The TypeScript compiler will ensure you implement all required methods. + * + * @example + * ```typescript + * import { createMetadataPlugin, type MetadataProvider } from "@codex/plugin-sdk"; + * + * const provider: MetadataProvider = { + * async search(params) { + * return { + * results: [{ + * externalId: "123", + * title: "Example", + * alternateTitles: [], + * relevanceScore: 0.95, + * }], + * }; + * }, + * async get(params) { + * return { + * externalId: params.externalId, + * externalUrl: "https://example.com/123", + * alternateTitles: [], + * genres: [], + * tags: [], + * authors: [], + * artists: [], + * externalLinks: [], + * }; + * }, + * }; + * + * createMetadataPlugin({ + * manifest: { + * name: "my-plugin", + * displayName: "My Plugin", + * version: "1.0.0", + * description: "Example plugin", + * author: "Me", + * protocolVersion: "1.0", + * capabilities: { metadataProvider: ["series"] }, + * }, + * provider, + * }); + * ``` + */ +export function createMetadataPlugin(options: MetadataPluginOptions): void { + const { manifest, provider, onInitialize, logLevel = "info" } = options; + const logger = createLogger({ name: manifest.name, level: logLevel }); + + logger.info(`Starting plugin: ${manifest.displayName} v${manifest.version}`); + + const rl = createInterface({ + input: process.stdin, + terminal: false, + }); + + rl.on("line", (line) => { + void handleLine(line, manifest, provider, onInitialize, logger); + }); + + rl.on("close", () => { + logger.info("stdin closed, shutting down"); + process.exit(0); + }); + + // Handle uncaught errors + process.on("uncaughtException", (error) => { + logger.error("Uncaught exception", error); + process.exit(1); + }); + + process.on("unhandledRejection", (reason) => { + logger.error("Unhandled rejection", reason); + }); +} + +// ============================================================================= +// Backwards Compatibility (deprecated) +// ============================================================================= + +/** + * @deprecated Use createMetadataPlugin instead + */ +export function createSeriesMetadataPlugin(options: SeriesMetadataPluginOptions): void { + // Convert legacy options to new format + const newOptions: MetadataPluginOptions = { + ...options, + manifest: { + ...options.manifest, + capabilities: { + ...options.manifest.capabilities, + metadataProvider: ["series"] as MetadataContentType[], + }, + }, + }; + createMetadataPlugin(newOptions); +} + +/** + * @deprecated Use MetadataPluginOptions instead + */ +export interface SeriesMetadataPluginOptions { + /** Plugin manifest - must have capabilities.seriesMetadataProvider: true */ + manifest: PluginManifest & { + capabilities: { seriesMetadataProvider: true }; + }; + /** SeriesMetadataProvider implementation */ + provider: MetadataProvider; + /** Called when plugin receives initialize with credentials/config */ + onInitialize?: (params: InitializeParams) => void | Promise; + /** Log level (default: "info") */ + logLevel?: "debug" | "info" | "warn" | "error"; +} + +// ============================================================================= +// Internal Implementation +// ============================================================================= + +async function handleLine( + line: string, + manifest: PluginManifest, + provider: MetadataProvider, + onInitialize: ((params: InitializeParams) => void | Promise) | undefined, + logger: Logger, +): Promise { + const trimmed = line.trim(); + if (!trimmed) return; + + let id: string | number | null = null; + + try { + const request = JSON.parse(trimmed) as JsonRpcRequest; + id = request.id; + + logger.debug(`Received request: ${request.method}`, { id: request.id }); + + const response = await handleRequest(request, manifest, provider, onInitialize, logger); + // Shutdown handler writes response directly and returns null + if (response !== null) { + writeResponse(response); + } + } catch (error) { + if (error instanceof SyntaxError) { + // JSON parse error + writeResponse({ + jsonrpc: "2.0", + id: null, + error: { + code: JSON_RPC_ERROR_CODES.PARSE_ERROR, + message: "Parse error: invalid JSON", + }, + }); + } else if (error instanceof PluginError) { + writeResponse({ + jsonrpc: "2.0", + id, + error: error.toJsonRpcError(), + }); + } else { + const message = error instanceof Error ? error.message : "Unknown error"; + logger.error("Request failed", error); + writeResponse({ + jsonrpc: "2.0", + id, + error: { + code: JSON_RPC_ERROR_CODES.INTERNAL_ERROR, + message, + }, + }); + } + } +} + +async function handleRequest( + request: JsonRpcRequest, + manifest: PluginManifest, + provider: MetadataProvider, + onInitialize: ((params: InitializeParams) => void | Promise) | undefined, + logger: Logger, +): Promise { + const { method, params, id } = request; + + switch (method) { + case "initialize": + // Call onInitialize callback if provided (to receive credentials/config) + if (onInitialize) { + await onInitialize(params as InitializeParams); + } + return { + jsonrpc: "2.0", + id, + result: manifest, + }; + + case "ping": + return { + jsonrpc: "2.0", + id, + result: "pong", + }; + + case "shutdown": { + logger.info("Shutdown requested"); + // Write response directly with callback to ensure it's flushed before exit + const response: JsonRpcResponse = { + jsonrpc: "2.0", + id, + result: null, + }; + process.stdout.write(`${JSON.stringify(response)}\n`, () => { + // Callback is called after the write is flushed to the OS + process.exit(0); + }); + // Return a sentinel that handleLine will recognize and skip normal writeResponse + return null as unknown as JsonRpcResponse; + } + + // Series metadata methods (scoped by content type) + case "metadata/series/search": { + const validationError = validateSearchParams(params); + if (validationError) { + return invalidParamsError(id, validationError); + } + return { + jsonrpc: "2.0", + id, + result: await provider.search(params as MetadataSearchParams), + }; + } + + case "metadata/series/get": { + const validationError = validateGetParams(params); + if (validationError) { + return invalidParamsError(id, validationError); + } + return { + jsonrpc: "2.0", + id, + result: await provider.get(params as MetadataGetParams), + }; + } + + case "metadata/series/match": { + if (!provider.match) { + return { + jsonrpc: "2.0", + id, + error: { + code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND, + message: "This plugin does not support match", + }, + }; + } + const validationError = validateMatchParams(params); + if (validationError) { + return invalidParamsError(id, validationError); + } + return { + jsonrpc: "2.0", + id, + result: await provider.match(params as MetadataMatchParams), + }; + } + + // Future: book metadata methods + // case "metadata/book/search": + // case "metadata/book/get": + // case "metadata/book/match": + + default: + return { + jsonrpc: "2.0", + id, + error: { + code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND, + message: `Method not found: ${method}`, + }, + }; + } +} + +function writeResponse(response: JsonRpcResponse): void { + // Write to stdout - this is the JSON-RPC channel + process.stdout.write(`${JSON.stringify(response)}\n`); +} diff --git a/plugins/sdk-typescript/src/types/capabilities.ts b/plugins/sdk-typescript/src/types/capabilities.ts new file mode 100644 index 00000000..c1cb7b67 --- /dev/null +++ b/plugins/sdk-typescript/src/types/capabilities.ts @@ -0,0 +1,125 @@ +/** + * Capability interfaces - type-safe contracts for plugin capabilities + * + * Plugins declare which content types they support in their manifest's + * capabilities.metadataProvider array. The SDK automatically routes + * scoped methods (e.g., metadata/series/search) to the provider. + * + * @example + * ```typescript + * // If manifest has capabilities.metadataProvider: ["series"], + * // the plugin must implement MetadataProvider + * const provider: MetadataProvider = { + * search: async (params) => { ... }, + * get: async (params) => { ... }, + * match: async (params) => { ... }, // optional + * }; + * ``` + */ + +import type { + MetadataGetParams, + MetadataMatchParams, + MetadataMatchResponse, + MetadataSearchParams, + MetadataSearchResponse, + PluginSeriesMetadata, +} from "./protocol.js"; + +// ============================================================================= +// Content Types +// ============================================================================= + +/** + * Content types that a metadata provider can support. + * Plugins declare which types they support in capabilities.metadataProvider. + */ +export type MetadataContentType = "series" | "book"; + +// ============================================================================= +// Metadata Provider Capability +// ============================================================================= + +/** + * Interface for plugins that provide metadata. + * + * Plugins implementing this capability can: + * - Search for content by query + * - Get full metadata by external ID + * - Optionally match existing content to provider entries + * + * The same interface is used for both series and book metadata. + * The content type is determined by the method being called: + * - metadata/series/search -> provider.search() + * - metadata/book/search -> provider.search() (when book support is added) + */ +export interface MetadataProvider { + /** + * Search for content matching a query + * + * @param params - Search parameters + * @returns Search results with relevance scores + */ + search(params: MetadataSearchParams): Promise; + + /** + * Get full metadata for a specific external ID + * + * @param params - Get parameters including external ID + * @returns Full metadata + */ + get(params: MetadataGetParams): Promise; + + /** + * Find the best match for existing content (optional) + * + * This is used for auto-matching during library scans. + * If not implemented, Codex will use search() and pick the top result. + * + * @param params - Match parameters including title and hints + * @returns Best match with confidence score + */ + match?(params: MetadataMatchParams): Promise; +} + +// ============================================================================= +// Future Capabilities (v2) +// ============================================================================= + +/** + * Interface for plugins that sync reading progress (syncProvider: true) + * @future v2 - Methods will be defined when sync capability is implemented + */ +// biome-ignore lint/suspicious/noEmptyInterface: Placeholder for future v2 capability +export interface SyncProvider {} + +/** + * Interface for plugins that provide recommendations (recommendationProvider: true) + * @future v2 - Methods will be defined when recommendation capability is implemented + */ +// biome-ignore lint/suspicious/noEmptyInterface: Placeholder for future v2 capability +export interface RecommendationProvider {} + +// ============================================================================= +// Type Helpers +// ============================================================================= + +/** + * Partial metadata provider - allows implementing only some methods + * Use this for testing or gradual implementation + */ +export type PartialMetadataProvider = Partial; + +// ============================================================================= +// Backwards Compatibility (deprecated) +// ============================================================================= + +/** + * @deprecated Use MetadataProvider instead + */ +export type SeriesMetadataProvider = MetadataProvider; + +/** + * @deprecated Use PartialMetadataProvider instead + */ +export type PartialSeriesMetadataProvider = PartialMetadataProvider; diff --git a/plugins/sdk-typescript/src/types/index.ts b/plugins/sdk-typescript/src/types/index.ts new file mode 100644 index 00000000..280b4544 --- /dev/null +++ b/plugins/sdk-typescript/src/types/index.ts @@ -0,0 +1,39 @@ +/** + * Re-export all types + */ + +// From capabilities - interface contracts +export type { + // Primary types + MetadataContentType, + MetadataProvider, + PartialMetadataProvider, + PartialSeriesMetadataProvider, + RecommendationProvider, + // Deprecated aliases + SeriesMetadataProvider, + SyncProvider, +} from "./capabilities.js"; + +// From manifest - plugin configuration types +export type { CredentialField, PluginCapabilities, PluginManifest } from "./manifest.js"; +export { hasBookMetadataProvider, hasSeriesMetadataProvider } from "./manifest.js"; + +// From protocol - JSON-RPC protocol types (these match Rust exactly) +export type { + AlternateTitle, + ExternalLink, + ExternalLinkType, + ExternalRating, + MetadataGetParams, + MetadataMatchParams, + MetadataMatchResponse, + MetadataSearchParams, + MetadataSearchResponse, + PluginSeriesMetadata, + ReadingDirection, + SearchResult, + SearchResultPreview, + SeriesStatus, +} from "./protocol.js"; +export * from "./rpc.js"; diff --git a/plugins/sdk-typescript/src/types/manifest.ts b/plugins/sdk-typescript/src/types/manifest.ts new file mode 100644 index 00000000..7eb41fca --- /dev/null +++ b/plugins/sdk-typescript/src/types/manifest.ts @@ -0,0 +1,111 @@ +/** + * Plugin manifest types - describes plugin capabilities and requirements + */ + +import type { MetadataContentType } from "./capabilities.js"; + +/** + * Credential field configuration for admin UI + */ +export interface CredentialField { + /** Unique key for this credential (used in environment variables) */ + key: string; + /** Human-readable label for the UI */ + label: string; + /** Help text explaining where to get this credential */ + description?: string; + /** Whether this credential is required */ + required: boolean; + /** Whether to mask this value in the UI (for passwords/API keys) */ + sensitive: boolean; + /** Input type for the UI */ + type: "text" | "password" | "url"; + /** Placeholder text */ + placeholder?: string; +} + +/** + * Plugin capabilities + */ +export interface PluginCapabilities { + /** + * Content types this plugin can provide metadata for. + * E.g., ["series"] or ["series", "book"] + */ + metadataProvider?: MetadataContentType[]; + /** Can sync reading progress with external service */ + syncProvider?: boolean; + /** Can provide recommendations */ + recommendationProvider?: boolean; +} + +/** + * Plugin manifest returned by the `initialize` method + */ +export interface PluginManifest { + /** Unique plugin identifier (lowercase, alphanumeric, hyphens) */ + name: string; + /** Human-readable name for UI display */ + displayName: string; + /** Plugin version (semver) */ + version: string; + /** Short description of what the plugin does */ + description: string; + /** Author name or organization */ + author: string; + /** Homepage URL (documentation, source code) */ + homepage?: string; + /** Icon URL (optional, for UI display) */ + icon?: string; + + /** Protocol version this plugin implements */ + protocolVersion: "1.0"; + + /** What this plugin can do */ + capabilities: PluginCapabilities; + + /** Credentials required from admin */ + requiredCredentials?: CredentialField[]; +} + +// ============================================================================= +// Type Guards for Manifest Validation +// ============================================================================= + +/** + * Type guard to check if manifest declares series metadata provider capability + */ +export function hasSeriesMetadataProvider(manifest: PluginManifest): manifest is PluginManifest & { + capabilities: { metadataProvider: MetadataContentType[] }; +} { + return ( + Array.isArray(manifest.capabilities.metadataProvider) && + manifest.capabilities.metadataProvider.includes("series") + ); +} + +/** + * Type guard to check if manifest declares book metadata provider capability + */ +export function hasBookMetadataProvider(manifest: PluginManifest): manifest is PluginManifest & { + capabilities: { metadataProvider: MetadataContentType[] }; +} { + return ( + Array.isArray(manifest.capabilities.metadataProvider) && + manifest.capabilities.metadataProvider.includes("book") + ); +} + +// ============================================================================= +// Backwards Compatibility (deprecated) +// ============================================================================= + +/** + * @deprecated Use PluginCapabilities with metadataProvider array instead + */ +export interface LegacyPluginCapabilities { + /** @deprecated Use metadataProvider: ["series"] instead */ + seriesMetadataProvider?: boolean; + syncProvider?: boolean; + recommendationProvider?: boolean; +} diff --git a/plugins/sdk-typescript/src/types/protocol.ts b/plugins/sdk-typescript/src/types/protocol.ts new file mode 100644 index 00000000..97365dce --- /dev/null +++ b/plugins/sdk-typescript/src/types/protocol.ts @@ -0,0 +1,224 @@ +/** + * Protocol types - these MUST match the Rust protocol exactly + * + * These types define the JSON-RPC protocol contract between plugins and Codex. + * Field names use camelCase to match JSON serialization. + * + * @see src/services/plugin/protocol.rs in the Codex backend + */ + +// ============================================================================= +// Metadata Search Types +// ============================================================================= + +/** + * Parameters for metadata/series/search method (and future metadata/book/search) + */ +export interface MetadataSearchParams { + /** Search query string */ + query: string; + /** Maximum number of results to return */ + limit?: number; + /** Pagination cursor from previous response */ + cursor?: string; +} + +/** + * Response from metadata/series/search method (and future metadata/book/search) + */ +export interface MetadataSearchResponse { + /** Search results */ + results: SearchResult[]; + /** Cursor for next page (if more results available) */ + nextCursor?: string; +} + +/** + * Individual search result + */ +export interface SearchResult { + /** External ID from the provider */ + externalId: string; + /** Primary title */ + title: string; + /** Alternative titles */ + alternateTitles: string[]; + /** Year of publication/release */ + year?: number; + /** Cover image URL */ + coverUrl?: string; + /** Relevance score (0.0-1.0, where 1.0 is perfect match) */ + relevanceScore?: number; + /** Preview data for displaying in search results */ + preview?: SearchResultPreview; +} + +/** + * Preview data shown in search result list + */ +export interface SearchResultPreview { + /** Publication status */ + status?: string; + /** Genres */ + genres: string[]; + /** Rating (normalized 0-10) */ + rating?: number; + /** Short description */ + description?: string; +} + +// ============================================================================= +// Metadata Get Types +// ============================================================================= + +/** + * Parameters for metadata/series/get method (and future metadata/book/get) + */ +export interface MetadataGetParams { + /** External ID from the provider */ + externalId: string; +} + +/** + * Full series metadata from a provider + */ +export interface PluginSeriesMetadata { + /** External ID from the provider */ + externalId: string; + /** URL to the series on the provider's website */ + externalUrl: string; + + // Core fields (all optional) + /** Primary title */ + title?: string; + /** Alternative titles with language info */ + alternateTitles: AlternateTitle[]; + /** Full description/summary */ + summary?: string; + /** Publication status */ + status?: SeriesStatus; + /** Year of first publication */ + year?: number; + + // Extended metadata + /** Expected total number of books in the series */ + totalBookCount?: number; + /** BCP47 language code (e.g., "en", "ja", "ko") */ + language?: string; + /** Age rating (e.g., 0, 13, 16, 18) */ + ageRating?: number; + /** Reading direction: "ltr", "rtl", or "ttb" */ + readingDirection?: ReadingDirection; + + // Taxonomy + /** Genres (e.g., "Action", "Romance") */ + genres: string[]; + /** Tags/themes (e.g., "Time Travel", "School Life") */ + tags: string[]; + + // Credits + /** Authors/writers */ + authors: string[]; + /** Artists (if different from authors) */ + artists: string[]; + /** Publisher name */ + publisher?: string; + + // Media + /** Cover image URL */ + coverUrl?: string; + /** Banner/background image URL */ + bannerUrl?: string; + + // Rating + /** External rating information (primary rating) */ + rating?: ExternalRating; + /** Multiple external ratings from different sources (e.g., AniList, MAL) */ + externalRatings?: ExternalRating[]; + + // External links + /** Links to other sites */ + externalLinks: ExternalLink[]; +} + +/** + * Alternate title with language info + */ +export interface AlternateTitle { + /** The title text */ + title: string; + /** ISO 639-1 language code (e.g., "en", "ja") */ + language?: string; + /** Title type (e.g., "romaji", "native", "english") */ + titleType?: string; +} + +/** + * Series publication status + * + * These values MUST match the backend's canonical SeriesStatus enum. + * @see src/db/entities/series_metadata.rs in the Codex backend + */ +export type SeriesStatus = "ongoing" | "ended" | "hiatus" | "abandoned" | "unknown"; + +/** + * Reading direction for content + */ +export type ReadingDirection = "ltr" | "rtl" | "ttb"; + +/** + * External rating from provider + */ +export interface ExternalRating { + /** Normalized score (0-100) */ + score: number; + /** Number of votes */ + voteCount?: number; + /** Source name (e.g., "mangaupdates") */ + source: string; +} + +/** + * External link to other sites + */ +export interface ExternalLink { + /** URL */ + url: string; + /** Display label */ + label: string; + /** Link type */ + linkType?: ExternalLinkType; +} + +/** + * Type of external link + */ +export type ExternalLinkType = "provider" | "official" | "social" | "purchase" | "read" | "other"; + +// ============================================================================= +// Metadata Match Types +// ============================================================================= + +/** + * Parameters for metadata/series/match method (and future metadata/book/match) + */ +export interface MetadataMatchParams { + /** Title to match against */ + title: string; + /** Year hint for matching */ + year?: number; + /** Author hint for matching */ + author?: string; +} + +/** + * Response from metadata/series/match method (and future metadata/book/match) + */ +export interface MetadataMatchResponse { + /** Best match result, or null if no confident match */ + match: SearchResult | null; + /** Confidence score (0.0-1.0) */ + confidence: number; + /** Alternative matches if confidence is low */ + alternatives?: SearchResult[]; +} diff --git a/plugins/sdk-typescript/src/types/rpc.ts b/plugins/sdk-typescript/src/types/rpc.ts new file mode 100644 index 00000000..92b61990 --- /dev/null +++ b/plugins/sdk-typescript/src/types/rpc.ts @@ -0,0 +1,62 @@ +/** + * JSON-RPC 2.0 types for plugin communication + */ + +export interface JsonRpcRequest { + jsonrpc: "2.0"; + id: string | number | null; + method: string; + params?: unknown; +} + +export interface JsonRpcSuccessResponse { + jsonrpc: "2.0"; + id: string | number | null; + result: unknown; +} + +export interface JsonRpcErrorResponse { + jsonrpc: "2.0"; + id: string | number | null; + error: JsonRpcError; +} + +export interface JsonRpcError { + code: number; + message: string; + data?: unknown; +} + +export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse; + +/** + * Standard JSON-RPC error codes + */ +export const JSON_RPC_ERROR_CODES = { + /** Invalid JSON was received */ + PARSE_ERROR: -32700, + /** The JSON sent is not a valid Request object */ + INVALID_REQUEST: -32600, + /** The method does not exist / is not available */ + METHOD_NOT_FOUND: -32601, + /** Invalid method parameter(s) */ + INVALID_PARAMS: -32602, + /** Internal JSON-RPC error */ + INTERNAL_ERROR: -32603, +} as const; + +/** + * Plugin-specific error codes (in the -32000 to -32099 range) + */ +export const PLUGIN_ERROR_CODES = { + /** Rate limited by external API */ + RATE_LIMITED: -32001, + /** Resource not found (e.g., series ID doesn't exist) */ + NOT_FOUND: -32002, + /** Authentication failed (invalid credentials) */ + AUTH_FAILED: -32003, + /** External API error */ + API_ERROR: -32004, + /** Plugin configuration error */ + CONFIG_ERROR: -32005, +} as const; diff --git a/plugins/sdk-typescript/tsconfig.json b/plugins/sdk-typescript/tsconfig.json new file mode 100644 index 00000000..a0e7d0e8 --- /dev/null +++ b/plugins/sdk-typescript/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/plugins/sdk-typescript/vitest.config.ts b/plugins/sdk-typescript/vitest.config.ts new file mode 100644 index 00000000..57b1e242 --- /dev/null +++ b/plugins/sdk-typescript/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + coverage: { + provider: "v8", + reporter: ["text", "html"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts", "src/types/**/*.ts"], + }, + }, +}); diff --git a/screenshots/output/libraries/add-library-formats-books.png b/screenshots/output/libraries/add-library-formats-books.png new file mode 100644 index 00000000..137c17bd Binary files /dev/null and b/screenshots/output/libraries/add-library-formats-books.png differ diff --git a/screenshots/output/libraries/add-library-formats-comics.png b/screenshots/output/libraries/add-library-formats-comics.png new file mode 100644 index 00000000..21c7696a Binary files /dev/null and b/screenshots/output/libraries/add-library-formats-comics.png differ diff --git a/screenshots/output/libraries/add-library-formats-manga.png b/screenshots/output/libraries/add-library-formats-manga.png new file mode 100644 index 00000000..ae695162 Binary files /dev/null and b/screenshots/output/libraries/add-library-formats-manga.png differ diff --git a/screenshots/output/libraries/add-library-general-books.png b/screenshots/output/libraries/add-library-general-books.png new file mode 100644 index 00000000..b6f76654 Binary files /dev/null and b/screenshots/output/libraries/add-library-general-books.png differ diff --git a/screenshots/output/libraries/add-library-general-comics.png b/screenshots/output/libraries/add-library-general-comics.png new file mode 100644 index 00000000..94529d86 Binary files /dev/null and b/screenshots/output/libraries/add-library-general-comics.png differ diff --git a/screenshots/output/libraries/add-library-general-manga.png b/screenshots/output/libraries/add-library-general-manga.png new file mode 100644 index 00000000..b98a457b Binary files /dev/null and b/screenshots/output/libraries/add-library-general-manga.png differ diff --git a/screenshots/output/libraries/add-library-scanning-books.png b/screenshots/output/libraries/add-library-scanning-books.png new file mode 100644 index 00000000..91ba7df8 Binary files /dev/null and b/screenshots/output/libraries/add-library-scanning-books.png differ diff --git a/screenshots/output/libraries/add-library-scanning-comics.png b/screenshots/output/libraries/add-library-scanning-comics.png new file mode 100644 index 00000000..725f9f48 Binary files /dev/null and b/screenshots/output/libraries/add-library-scanning-comics.png differ diff --git a/screenshots/output/libraries/add-library-scanning-manga.png b/screenshots/output/libraries/add-library-scanning-manga.png new file mode 100644 index 00000000..95bfc4f0 Binary files /dev/null and b/screenshots/output/libraries/add-library-scanning-manga.png differ diff --git a/screenshots/output/libraries/add-library-strategy-books.png b/screenshots/output/libraries/add-library-strategy-books.png new file mode 100644 index 00000000..394de7dd Binary files /dev/null and b/screenshots/output/libraries/add-library-strategy-books.png differ diff --git a/screenshots/output/libraries/add-library-strategy-comics.png b/screenshots/output/libraries/add-library-strategy-comics.png new file mode 100644 index 00000000..e5c23cb2 Binary files /dev/null and b/screenshots/output/libraries/add-library-strategy-comics.png differ diff --git a/screenshots/output/libraries/add-library-strategy-manga.png b/screenshots/output/libraries/add-library-strategy-manga.png new file mode 100644 index 00000000..f7dedad7 Binary files /dev/null and b/screenshots/output/libraries/add-library-strategy-manga.png differ diff --git a/screenshots/output/libraries/all-libraries-books.png b/screenshots/output/libraries/all-libraries-books.png new file mode 100644 index 00000000..3e113808 Binary files /dev/null and b/screenshots/output/libraries/all-libraries-books.png differ diff --git a/screenshots/output/libraries/all-libraries-series.png b/screenshots/output/libraries/all-libraries-series.png new file mode 100644 index 00000000..8d734200 Binary files /dev/null and b/screenshots/output/libraries/all-libraries-series.png differ diff --git a/screenshots/output/libraries/book-detail.png b/screenshots/output/libraries/book-detail.png new file mode 100644 index 00000000..4cd67caa Binary files /dev/null and b/screenshots/output/libraries/book-detail.png differ diff --git a/screenshots/output/libraries/home-with-libraries.png b/screenshots/output/libraries/home-with-libraries.png new file mode 100644 index 00000000..b65bbb8d Binary files /dev/null and b/screenshots/output/libraries/home-with-libraries.png differ diff --git a/screenshots/output/libraries/library-detail-series.png b/screenshots/output/libraries/library-detail-series.png new file mode 100644 index 00000000..5a48fbc2 Binary files /dev/null and b/screenshots/output/libraries/library-detail-series.png differ diff --git a/screenshots/output/libraries/series-detail.png b/screenshots/output/libraries/series-detail.png new file mode 100644 index 00000000..cb32dbd5 Binary files /dev/null and b/screenshots/output/libraries/series-detail.png differ diff --git a/screenshots/output/navigation/home-dashboard.png b/screenshots/output/navigation/home-dashboard.png new file mode 100644 index 00000000..754173d7 Binary files /dev/null and b/screenshots/output/navigation/home-dashboard.png differ diff --git a/screenshots/output/navigation/login-page.png b/screenshots/output/navigation/login-page.png new file mode 100644 index 00000000..5b5649d7 Binary files /dev/null and b/screenshots/output/navigation/login-page.png differ diff --git a/screenshots/output/navigation/search-dropdown.png b/screenshots/output/navigation/search-dropdown.png new file mode 100644 index 00000000..53f0e195 Binary files /dev/null and b/screenshots/output/navigation/search-dropdown.png differ diff --git a/screenshots/output/navigation/search-results.png b/screenshots/output/navigation/search-results.png new file mode 100644 index 00000000..e68ac90b Binary files /dev/null and b/screenshots/output/navigation/search-results.png differ diff --git a/screenshots/output/navigation/sidebar-settings-expanded.png b/screenshots/output/navigation/sidebar-settings-expanded.png new file mode 100644 index 00000000..bd2dff10 Binary files /dev/null and b/screenshots/output/navigation/sidebar-settings-expanded.png differ diff --git a/screenshots/output/plugins/apply-success.png b/screenshots/output/plugins/apply-success.png new file mode 100644 index 00000000..7bbeb461 Binary files /dev/null and b/screenshots/output/plugins/apply-success.png differ diff --git a/screenshots/output/plugins/create-credentials.png b/screenshots/output/plugins/create-credentials.png new file mode 100644 index 00000000..024839c4 Binary files /dev/null and b/screenshots/output/plugins/create-credentials.png differ diff --git a/screenshots/output/plugins/create-execution.png b/screenshots/output/plugins/create-execution.png new file mode 100644 index 00000000..a141410a Binary files /dev/null and b/screenshots/output/plugins/create-execution.png differ diff --git a/screenshots/output/plugins/create-general.png b/screenshots/output/plugins/create-general.png new file mode 100644 index 00000000..79ca8811 Binary files /dev/null and b/screenshots/output/plugins/create-general.png differ diff --git a/screenshots/output/plugins/create-permissions.png b/screenshots/output/plugins/create-permissions.png new file mode 100644 index 00000000..2eb32eda Binary files /dev/null and b/screenshots/output/plugins/create-permissions.png differ diff --git a/screenshots/output/plugins/library-auto-match-success.png b/screenshots/output/plugins/library-auto-match-success.png new file mode 100644 index 00000000..f9dc3443 Binary files /dev/null and b/screenshots/output/plugins/library-auto-match-success.png differ diff --git a/screenshots/output/plugins/library-sidebar-plugin-dropdown.png b/screenshots/output/plugins/library-sidebar-plugin-dropdown.png new file mode 100644 index 00000000..a66b14c6 Binary files /dev/null and b/screenshots/output/plugins/library-sidebar-plugin-dropdown.png differ diff --git a/screenshots/output/plugins/metadata-preview.png b/screenshots/output/plugins/metadata-preview.png new file mode 100644 index 00000000..3ba516da Binary files /dev/null and b/screenshots/output/plugins/metadata-preview.png differ diff --git a/screenshots/output/plugins/search-results.png b/screenshots/output/plugins/search-results.png new file mode 100644 index 00000000..f8a0c885 Binary files /dev/null and b/screenshots/output/plugins/search-results.png differ diff --git a/screenshots/output/plugins/series-detail-after-plugin.png b/screenshots/output/plugins/series-detail-after-plugin.png new file mode 100644 index 00000000..52ba9daf Binary files /dev/null and b/screenshots/output/plugins/series-detail-after-plugin.png differ diff --git a/screenshots/output/plugins/series-detail-plugin-dropdown.png b/screenshots/output/plugins/series-detail-plugin-dropdown.png new file mode 100644 index 00000000..21dc657d Binary files /dev/null and b/screenshots/output/plugins/series-detail-plugin-dropdown.png differ diff --git a/screenshots/output/plugins/settings-plugins-with-echo.png b/screenshots/output/plugins/settings-plugins-with-echo.png new file mode 100644 index 00000000..5b552651 Binary files /dev/null and b/screenshots/output/plugins/settings-plugins-with-echo.png differ diff --git a/screenshots/output/plugins/settings-plugins.png b/screenshots/output/plugins/settings-plugins.png new file mode 100644 index 00000000..98e6ead3 Binary files /dev/null and b/screenshots/output/plugins/settings-plugins.png differ diff --git a/screenshots/output/reader/comic-settings.png b/screenshots/output/reader/comic-settings.png new file mode 100644 index 00000000..7b85e69d Binary files /dev/null and b/screenshots/output/reader/comic-settings.png differ diff --git a/screenshots/output/reader/comic-toolbar.png b/screenshots/output/reader/comic-toolbar.png new file mode 100644 index 00000000..47463b05 Binary files /dev/null and b/screenshots/output/reader/comic-toolbar.png differ diff --git a/screenshots/output/reader/comic-view.png b/screenshots/output/reader/comic-view.png new file mode 100644 index 00000000..2f9fde05 Binary files /dev/null and b/screenshots/output/reader/comic-view.png differ diff --git a/screenshots/output/reader/epub-settings.png b/screenshots/output/reader/epub-settings.png new file mode 100644 index 00000000..59261bfa Binary files /dev/null and b/screenshots/output/reader/epub-settings.png differ diff --git a/screenshots/output/reader/epub-toolbar.png b/screenshots/output/reader/epub-toolbar.png new file mode 100644 index 00000000..e8ab9faf Binary files /dev/null and b/screenshots/output/reader/epub-toolbar.png differ diff --git a/screenshots/output/reader/epub-view.png b/screenshots/output/reader/epub-view.png new file mode 100644 index 00000000..335d1365 Binary files /dev/null and b/screenshots/output/reader/epub-view.png differ diff --git a/screenshots/output/reader/pdf-settings.png b/screenshots/output/reader/pdf-settings.png new file mode 100644 index 00000000..bc20c7e6 Binary files /dev/null and b/screenshots/output/reader/pdf-settings.png differ diff --git a/screenshots/output/reader/pdf-toolbar.png b/screenshots/output/reader/pdf-toolbar.png new file mode 100644 index 00000000..d3c8af3a Binary files /dev/null and b/screenshots/output/reader/pdf-toolbar.png differ diff --git a/screenshots/output/reader/pdf-view.png b/screenshots/output/reader/pdf-view.png new file mode 100644 index 00000000..524f7703 Binary files /dev/null and b/screenshots/output/reader/pdf-view.png differ diff --git a/screenshots/output/settings/book-errors.png b/screenshots/output/settings/book-errors.png new file mode 100644 index 00000000..04cb6c04 Binary files /dev/null and b/screenshots/output/settings/book-errors.png differ diff --git a/screenshots/output/settings/cleanup.png b/screenshots/output/settings/cleanup.png new file mode 100644 index 00000000..dc279099 Binary files /dev/null and b/screenshots/output/settings/cleanup.png differ diff --git a/screenshots/output/settings/duplicates.png b/screenshots/output/settings/duplicates.png new file mode 100644 index 00000000..98d57f42 Binary files /dev/null and b/screenshots/output/settings/duplicates.png differ diff --git a/screenshots/output/settings/metrics-plugins-expanded.png b/screenshots/output/settings/metrics-plugins-expanded.png new file mode 100644 index 00000000..a177e3ce Binary files /dev/null and b/screenshots/output/settings/metrics-plugins-expanded.png differ diff --git a/screenshots/output/settings/metrics-plugins-overview.png b/screenshots/output/settings/metrics-plugins-overview.png new file mode 100644 index 00000000..aace407b Binary files /dev/null and b/screenshots/output/settings/metrics-plugins-overview.png differ diff --git a/screenshots/output/settings/metrics-tasks.png b/screenshots/output/settings/metrics-tasks.png new file mode 100644 index 00000000..6a85d71e Binary files /dev/null and b/screenshots/output/settings/metrics-tasks.png differ diff --git a/screenshots/output/settings/metrics.png b/screenshots/output/settings/metrics.png new file mode 100644 index 00000000..b5bfc849 Binary files /dev/null and b/screenshots/output/settings/metrics.png differ diff --git a/screenshots/output/settings/pdf-cache.png b/screenshots/output/settings/pdf-cache.png new file mode 100644 index 00000000..287635ea Binary files /dev/null and b/screenshots/output/settings/pdf-cache.png differ diff --git a/screenshots/output/settings/profile-api-keys.png b/screenshots/output/settings/profile-api-keys.png new file mode 100644 index 00000000..64217e86 Binary files /dev/null and b/screenshots/output/settings/profile-api-keys.png differ diff --git a/screenshots/output/settings/profile-preferences.png b/screenshots/output/settings/profile-preferences.png new file mode 100644 index 00000000..20d51df6 Binary files /dev/null and b/screenshots/output/settings/profile-preferences.png differ diff --git a/screenshots/output/settings/profile.png b/screenshots/output/settings/profile.png new file mode 100644 index 00000000..3ff6ad84 Binary files /dev/null and b/screenshots/output/settings/profile.png differ diff --git a/screenshots/output/settings/server-custom-metadata-templates.png b/screenshots/output/settings/server-custom-metadata-templates.png new file mode 100644 index 00000000..d5d6e390 Binary files /dev/null and b/screenshots/output/settings/server-custom-metadata-templates.png differ diff --git a/screenshots/output/settings/server-custom-metadata.png b/screenshots/output/settings/server-custom-metadata.png new file mode 100644 index 00000000..b63d547a Binary files /dev/null and b/screenshots/output/settings/server-custom-metadata.png differ diff --git a/screenshots/output/settings/server.png b/screenshots/output/settings/server.png new file mode 100644 index 00000000..0d0ffc28 Binary files /dev/null and b/screenshots/output/settings/server.png differ diff --git a/screenshots/output/settings/sharing-tags.png b/screenshots/output/settings/sharing-tags.png new file mode 100644 index 00000000..91c5bcd0 Binary files /dev/null and b/screenshots/output/settings/sharing-tags.png differ diff --git a/screenshots/output/settings/tasks.png b/screenshots/output/settings/tasks.png new file mode 100644 index 00000000..453691d6 Binary files /dev/null and b/screenshots/output/settings/tasks.png differ diff --git a/screenshots/output/settings/users.png b/screenshots/output/settings/users.png new file mode 100644 index 00000000..0fc4e167 Binary files /dev/null and b/screenshots/output/settings/users.png differ diff --git a/screenshots/output/setup/complete-dashboard.png b/screenshots/output/setup/complete-dashboard.png new file mode 100644 index 00000000..31ccffe5 Binary files /dev/null and b/screenshots/output/setup/complete-dashboard.png differ diff --git a/screenshots/output/setup/wizard-step1-empty.png b/screenshots/output/setup/wizard-step1-empty.png new file mode 100644 index 00000000..718c7e8e Binary files /dev/null and b/screenshots/output/setup/wizard-step1-empty.png differ diff --git a/screenshots/output/setup/wizard-step1-filled.png b/screenshots/output/setup/wizard-step1-filled.png new file mode 100644 index 00000000..fe9433be Binary files /dev/null and b/screenshots/output/setup/wizard-step1-filled.png differ diff --git a/screenshots/output/setup/wizard-step2-advanced-settings.png b/screenshots/output/setup/wizard-step2-advanced-settings.png new file mode 100644 index 00000000..71db5c39 Binary files /dev/null and b/screenshots/output/setup/wizard-step2-advanced-settings.png differ diff --git a/screenshots/output/setup/wizard-step2-basic-settings.png b/screenshots/output/setup/wizard-step2-basic-settings.png new file mode 100644 index 00000000..aabc77fc Binary files /dev/null and b/screenshots/output/setup/wizard-step2-basic-settings.png differ diff --git a/screenshots/output/setup/wizard-step2-skip.png b/screenshots/output/setup/wizard-step2-skip.png new file mode 100644 index 00000000..a8850b93 Binary files /dev/null and b/screenshots/output/setup/wizard-step2-skip.png differ diff --git a/screenshots/scripts/capture.ts b/screenshots/scripts/capture.ts index a35a039d..0a4903c8 100644 --- a/screenshots/scripts/capture.ts +++ b/screenshots/scripts/capture.ts @@ -78,6 +78,14 @@ async function main(): Promise { console.log("⚠️ Reader scenario not found, skipping"); } + try { + const plugins = await import("./scenarios/plugins.js"); + scenarios.push({ name: "Plugins", run: plugins.run }); + } catch { + console.log("⚠️ Plugins scenario not found, skipping"); + } + + // Navigation runs last since it logs out try { const navigation = await import("./scenarios/navigation.js"); scenarios.push({ name: "Navigation", run: navigation.run }); diff --git a/screenshots/scripts/scenarios/libraries.ts b/screenshots/scripts/scenarios/libraries.ts index af3f7faa..929e576d 100644 --- a/screenshots/scripts/scenarios/libraries.ts +++ b/screenshots/scripts/scenarios/libraries.ts @@ -35,19 +35,19 @@ export async function run(page: Page, context: BrowserContext): Promise { await page.goto("/"); await waitForPageReady(page); await waitForThumbnails(page); - await captureScreenshot(page, "10-home-with-libraries"); + await captureScreenshot(page, "libraries/home-with-libraries"); // Navigate to All Libraries view await page.goto("/libraries/all/series"); await waitForPageReady(page); await waitForThumbnails(page); - await captureScreenshot(page, "11-all-libraries-series"); + await captureScreenshot(page, "libraries/all-libraries-series"); // Switch to books view await page.goto("/libraries/all/books"); await waitForPageReady(page); await waitForThumbnails(page); - await captureScreenshot(page, "12-all-libraries-books"); + await captureScreenshot(page, "libraries/all-libraries-books"); // Get first library from sidebar and navigate to it const libraryLinks = await page.$$('nav a[href^="/libraries/"]'); @@ -59,7 +59,7 @@ export async function run(page: Page, context: BrowserContext): Promise { await link.click(); await waitForPageReady(page); await waitForThumbnails(page); - await captureScreenshot(page, "13-library-detail-series"); + await captureScreenshot(page, "libraries/library-detail-series"); break; } } @@ -71,7 +71,7 @@ export async function run(page: Page, context: BrowserContext): Promise { await seriesCards[0].click(); await waitForPageReady(page); await waitForThumbnails(page); - await captureScreenshot(page, "14-series-detail"); + await captureScreenshot(page, "libraries/series-detail"); // Try to find and click on a book in the series const bookCards = await page.$$('[data-testid="book-card"], .book-card, a[href*="/books/"]'); @@ -79,7 +79,7 @@ export async function run(page: Page, context: BrowserContext): Promise { await bookCards[0].click(); await waitForPageReady(page); await waitForThumbnails(page); - await captureScreenshot(page, "15-book-detail"); + await captureScreenshot(page, "libraries/book-detail"); } } } @@ -139,7 +139,7 @@ async function createLibrary(page: Page, libraryConfig: LibraryConfig): Promise< } // Capture filled General tab - await captureScreenshot(page, `07-add-library-general-${nameLower}`); + await captureScreenshot(page, `libraries/add-library-general-${nameLower}`); // === STRATEGY TAB === const strategyTab = await page.$('button[role="tab"]:has-text("Strategy")'); @@ -182,7 +182,7 @@ async function createLibrary(page: Page, libraryConfig: LibraryConfig): Promise< // Default is series_volume, no change needed for comics/manga // Capture Strategy tab - await captureScreenshot(page, `08-add-library-strategy-${nameLower}`); + await captureScreenshot(page, `libraries/add-library-strategy-${nameLower}`); } // === FORMATS TAB === @@ -216,7 +216,7 @@ async function createLibrary(page: Page, libraryConfig: LibraryConfig): Promise< } // Capture Formats tab - await captureScreenshot(page, `09-add-library-formats-${nameLower}`); + await captureScreenshot(page, `libraries/add-library-formats-${nameLower}`); } // === SCANNING TAB === @@ -255,7 +255,7 @@ async function createLibrary(page: Page, libraryConfig: LibraryConfig): Promise< } // Capture Scanning tab - await captureScreenshot(page, `10-add-library-scanning-${nameLower}`); + await captureScreenshot(page, `libraries/add-library-scanning-${nameLower}`); } // Click Create Library button diff --git a/screenshots/scripts/scenarios/navigation.ts b/screenshots/scripts/scenarios/navigation.ts index 62b1d504..11fd8bdb 100644 --- a/screenshots/scripts/scenarios/navigation.ts +++ b/screenshots/scripts/scenarios/navigation.ts @@ -19,7 +19,7 @@ export async function run(page: Page, _context: BrowserContext): Promise { await page.goto("/"); await waitForPageReady(page); await waitForImages(page); - await captureScreenshot(page, "50-home-dashboard"); + await captureScreenshot(page, "navigation/home-dashboard"); // Capture sidebar expanded with settings console.log(" 📷 Sidebar with settings"); @@ -28,14 +28,14 @@ export async function run(page: Page, _context: BrowserContext): Promise { if (settingsNavItem) { await settingsNavItem.click(); await page.waitForTimeout(300); - await captureScreenshot(page, "51-sidebar-settings-expanded"); + await captureScreenshot(page, "navigation/sidebar-settings-expanded"); } // Capture login page (logout first) console.log(" 📷 Login page"); await logout(page); await waitForPageReady(page); - await captureScreenshot(page, "52-login-page"); + await captureScreenshot(page, "navigation/login-page"); } /** @@ -54,7 +54,15 @@ async function captureSearchPage(page: Page): Promise { await page.waitForTimeout(200); // Type a search query (needs at least 2 characters) - await searchInput.fill("cook"); + await searchInput.fill("hello"); + + // Wait for search dropdown/suggestions to appear + await page.waitForTimeout(800); + + // Capture the search dropdown before pressing Enter + await captureScreenshot(page, "navigation/search-dropdown"); + + // Press Enter to go to full search results page await page.keyboard.press("Enter"); // Wait for search results @@ -66,7 +74,7 @@ async function captureSearchPage(page: Page): Promise { await waitForImages(page); await page.waitForTimeout(500); - await captureScreenshot(page, "53-search-results"); + await captureScreenshot(page, "navigation/search-results"); } else { console.log(" ⚠️ Search input not found"); } diff --git a/screenshots/scripts/scenarios/plugins.ts b/screenshots/scripts/scenarios/plugins.ts new file mode 100644 index 00000000..673025c5 --- /dev/null +++ b/screenshots/scripts/scenarios/plugins.ts @@ -0,0 +1,437 @@ +import { Page, BrowserContext } from "playwright"; +import { captureScreenshot } from "../utils/screenshot.js"; +import { waitForPageReady } from "../utils/wait.js"; + +/** + * Plugins scenario + * Captures plugin creation, series detail plugin actions, and library auto-match + */ +export async function run(page: Page, _context: BrowserContext): Promise { + console.log(" 🔌 Capturing plugins screenshots..."); + + // Part 1: Create a plugin in settings + await createPluginScreenshots(page); + + // Part 2: Series detail page - plugin dropdown and metadata flow + await seriesDetailPluginScreenshots(page); + + // Part 3: Library sidebar - auto-match + await libraryAutoMatchScreenshots(page); + + // Part 4: Plugin Metrics + await pluginMetricsScreenshots(page); +} + +/** + * Create a plugin through the settings UI + */ +async function createPluginScreenshots(page: Page): Promise { + console.log(" 📷 Plugin Settings - Create Plugin Flow"); + + // Navigate to plugins settings + await page.goto("/settings/plugins"); + await waitForPageReady(page); + await page.waitForTimeout(500); + + // Capture initial plugins page (may be empty or have existing plugins) + await captureScreenshot(page, "plugins/settings-plugins"); + + // Click "Add Plugin" button - Mantine Button component + const addPluginButton = page.locator('button:has-text("Add Plugin")').first(); + if ((await addPluginButton.count()) === 0) { + console.log(" ⚠️ Add Plugin button not found"); + return; + } + + await addPluginButton.click(); + await page.waitForSelector('[role="dialog"], .mantine-Modal-content', { state: "visible", timeout: 5000 }); + await page.waitForTimeout(500); + + // === GENERAL TAB (First Tab) === + // Fill in plugin details + await page.fill('input[placeholder="mangabaka"]', "echo"); + await page.waitForTimeout(100); + + await page.fill('input[placeholder="MangaBaka"]', "Echo"); + await page.waitForTimeout(100); + + // Fill description + const descriptionTextarea = await page.$('textarea[placeholder*="MangaBaka"]'); + if (descriptionTextarea) { + await descriptionTextarea.fill("Echo plugin"); + await page.waitForTimeout(100); + } + + // Enable the plugin immediately - click the Switch label/track, not the hidden input + const enableSwitch = page.locator('label:has-text("Enable immediately")').first(); + if ((await enableSwitch.count()) > 0) { + await enableSwitch.click(); + await page.waitForTimeout(100); + } + + // Capture General tab + await captureScreenshot(page, "plugins/create-general"); + + // === EXECUTION TAB (Second Tab) === + const executionTab = await page.$('button[role="tab"]:has-text("Execution")'); + if (executionTab) { + await executionTab.click(); + await page.waitForTimeout(500); + + // Fill command - find the command input by its placeholder + const commandInput = page.locator('input[placeholder="node"]').first(); + await commandInput.fill("npx"); + await page.waitForTimeout(100); + + // Fill arguments - find by placeholder that contains "mangabaka" (the args textarea) + const argsTextarea = page.locator('textarea[placeholder*="mangabaka"]').first(); + await argsTextarea.fill("-y @codex/plugin-metadata-echo@1.0.0"); + await page.waitForTimeout(100); + + // Capture Execution tab with npx command + await captureScreenshot(page, "plugins/create-execution"); + + // Now change command to node and arguments to local path (per instructions) + await commandInput.fill("node"); + await page.waitForTimeout(100); + + await argsTextarea.fill("/opt/codex/plugins/metadata-echo/dist/index.js"); + await page.waitForTimeout(100); + } else { + console.log(" ⚠️ Execution tab not found"); + } + + // === PERMISSIONS TAB (Third Tab) === + const permissionsTab = await page.$('button[role="tab"]:has-text("Permissions")'); + if (permissionsTab) { + await permissionsTab.click(); + await page.waitForTimeout(300); + + // Select permissions using MultiSelect + // Click on Permissions MultiSelect + const permissionsSelect = await page.locator('label:has-text("Permissions")').locator('..').locator('.mantine-MultiSelect-input').first(); + if (await permissionsSelect.count() > 0) { + await permissionsSelect.click(); + await page.waitForTimeout(300); + + // Select "Read metadata" + const readOption = await page.$('[role="option"]:has-text("Read")'); + if (readOption) { + await readOption.click(); + await page.waitForTimeout(200); + } + + // Click again to select more + await permissionsSelect.click(); + await page.waitForTimeout(300); + + // Select "Write All metadata" + const writeAllOption = await page.$('[role="option"]:has-text("Write All")'); + if (writeAllOption) { + await writeAllOption.click(); + await page.waitForTimeout(200); + } + + // Click outside to close dropdown + await page.keyboard.press("Escape"); + await page.waitForTimeout(200); + } + + // Select scopes using MultiSelect + const scopesSelect = await page.locator('label:has-text("Scopes")').locator('..').locator('.mantine-MultiSelect-input').first(); + if (await scopesSelect.count() > 0) { + await scopesSelect.click(); + await page.waitForTimeout(300); + + // Select "series:detail" + const seriesDetailOption = await page.$('[role="option"]:has-text("Series Detail")'); + if (seriesDetailOption) { + await seriesDetailOption.click(); + await page.waitForTimeout(200); + } + + // Click again for more + await scopesSelect.click(); + await page.waitForTimeout(300); + + // Select "library:detail" + const libraryDetailOption = await page.$('[role="option"]:has-text("Library Detail")'); + if (libraryDetailOption) { + await libraryDetailOption.click(); + await page.waitForTimeout(200); + } + + // Close dropdown + await page.keyboard.press("Escape"); + await page.waitForTimeout(200); + } + + // Capture Permissions tab + await captureScreenshot(page, "plugins/create-permissions"); + } + + // === CREDENTIALS TAB (Fourth Tab) === + const credentialsTab = await page.$('button[role="tab"]:has-text("Credentials")'); + if (credentialsTab) { + await credentialsTab.click(); + await page.waitForTimeout(300); + + // Fill credentials JSON with fake data + const credentialsTextarea = await page.$('textarea[placeholder*="api_key"]'); + if (credentialsTextarea) { + await credentialsTextarea.fill('{\n "api_key": "demo-key-12345",\n "secret": "demo-secret"\n}'); + await page.waitForTimeout(100); + } + + // Capture Credentials tab + await captureScreenshot(page, "plugins/create-credentials"); + } + + // Create the plugin - use text selector for reliability + const createButton = page.locator('button:has-text("Create Plugin")').first(); + if ((await createButton.count()) > 0) { + await createButton.click(); + await page.waitForTimeout(2000); // Give more time for API call + + // Check if modal is still open (indicates validation error) + const modalStillOpen = await page.$('[role="dialog"], .mantine-Modal-content'); + if (modalStillOpen) { + console.log(" ⚠️ Modal still open - plugin creation may have failed"); + // Take a screenshot to see the error state + await captureScreenshot(page, "plugins/create-error"); + // Try to close the modal + await page.keyboard.press("Escape"); + await page.waitForTimeout(500); + } + + // Wait for modal to close (API call may take time) + await page.waitForSelector('[role="dialog"], .mantine-Modal-content', { state: "hidden", timeout: 15000 }).catch(() => {}); + + // Wait for notification to appear and disappear + await page.waitForTimeout(3000); + await waitForPageReady(page); + } + + // Click the test button to verify plugin connection + const testButton = page.locator('button:has(svg.tabler-icon-player-play)').first(); + if ((await testButton.count()) > 0) { + await testButton.click(); + // Wait for test to complete and notification to appear + await page.waitForTimeout(2000); + } + + // Capture plugins list with new plugin (after test) + await captureScreenshot(page, "plugins/settings-plugins-with-echo"); + + console.log(" ✓ Plugin creation screenshots captured"); +} + +/** + * Series detail page - plugin dropdown and metadata flow + */ +async function seriesDetailPluginScreenshots(page: Page): Promise { + console.log(" 📷 Series Detail - Plugin Actions"); + + // Navigate to the manga library's series view + // First, find the manga library by clicking its link in the sidebar + const mangaLibraryLink = page.locator('nav a[href*="/libraries/"]:has-text("Manga")').first(); + if ((await mangaLibraryLink.count()) > 0) { + await mangaLibraryLink.click(); + } else { + // Fallback to all libraries if manga not found + await page.goto("/libraries/all/series"); + } + await waitForPageReady(page); + await page.waitForTimeout(500); + + // Click on first series card + const seriesCard = await page.$('[data-testid="series-card"], .series-card, a[href*="/series/"]'); + if (!seriesCard) { + console.log(" ⚠️ No series found, skipping series detail screenshots"); + return; + } + + await seriesCard.click(); + await waitForPageReady(page); + await page.waitForTimeout(500); + + // Find and click the actions menu button (IconDotsVertical - three vertical dots) + // Target the menu button in the series header Grid, not the sidebar + // Use a more specific selector: find the ActionIcon with size="lg" that contains the dots icon + // The sidebar uses smaller buttons without the "lg" size variant + const actionsMenu = page.locator('.mantine-Grid-root button:has(svg.tabler-icon-dots-vertical)').first(); + if ((await actionsMenu.count()) === 0) { + console.log(" ⚠️ Actions menu not found on series detail"); + return; + } + + await actionsMenu.click(); + await page.waitForTimeout(500); + + // Capture dropdown showing plugin options + await captureScreenshot(page, "plugins/series-detail-plugin-dropdown"); + + // Click on "Echo" plugin in "Fetch Metadata" section + const fetchMetadataEcho = await page.$('[role="menuitem"]:has-text("Echo"), .mantine-Menu-item:has-text("Echo")'); + if (!fetchMetadataEcho) { + console.log(" ⚠️ Echo plugin not found in menu"); + // Close menu + await page.keyboard.press("Escape"); + return; + } + + await fetchMetadataEcho.click(); + await page.waitForTimeout(500); + + // Wait for search modal to open + await page.waitForSelector('[role="dialog"], .mantine-Modal-content', { state: "visible", timeout: 5000 }); + await page.waitForTimeout(1000); + + // Capture search results + await captureScreenshot(page, "plugins/search-results"); + + // Click on first search result (div with cursor: pointer inside the results stack) + const searchResult = await page.$('.mantine-Modal-content .mantine-Stack-root .mantine-Stack-root > div[style*="cursor: pointer"]'); + if (searchResult) { + await searchResult.click(); + await page.waitForTimeout(500); + + // Wait for preview to load + await waitForPageReady(page); + await page.waitForTimeout(500); + + // Capture metadata preview + await captureScreenshot(page, "plugins/metadata-preview"); + + // Click Apply button + const applyButton = await page.$('button:has-text("Apply")'); + if (applyButton) { + await applyButton.click(); + await page.waitForTimeout(1000); + + // Capture success state + await captureScreenshot(page, "plugins/apply-success"); + + // Close the success modal (X button in header) + const closeButton = await page.$('.mantine-Modal-close'); + if (closeButton) { + await closeButton.click(); + await page.waitForTimeout(500); + } else { + await page.keyboard.press("Escape"); + await page.waitForTimeout(300); + } + } else { + console.log(" ⚠️ No apply button found"); + } + } else { + console.log(" ⚠️ No search result found"); + } + + // Wait for modal to close + await page.waitForSelector('[role="dialog"], .mantine-Modal-content', { state: "hidden", timeout: 5000 }).catch(() => {}); + await waitForPageReady(page); + await page.waitForTimeout(300); + + // Capture series detail page after metadata applied + await captureScreenshot(page, "plugins/series-detail-after-plugin"); + + console.log(" ✓ Series detail plugin screenshots captured"); +} + +/** + * Library sidebar - auto-match feature + */ +async function libraryAutoMatchScreenshots(page: Page): Promise { + console.log(" 📷 Library Sidebar - Auto Match"); + + // Navigate to home to access the sidebar + await page.goto("/"); + await waitForPageReady(page); + await page.waitForTimeout(500); + + // Find the Manga library's menu button in the sidebar + // Look for the NavLink containing "Manga" text and find its menu button + const mangaNavLink = page.locator('nav .mantine-NavLink-root:has-text("Manga")').first(); + if ((await mangaNavLink.count()) === 0) { + console.log(" ⚠️ Manga library not found in sidebar"); + return; + } + + // Click the menu button within the Manga library NavLink + const mangaMenuButton = mangaNavLink.locator('button:has(svg.tabler-icon-dots-vertical)'); + if ((await mangaMenuButton.count()) === 0) { + console.log(" ⚠️ Manga library menu button not found"); + return; + } + + await mangaMenuButton.click(); + await page.waitForTimeout(500); + + // Capture library dropdown showing plugin auto-match options + await captureScreenshot(page, "plugins/library-sidebar-plugin-dropdown"); + + // Click on "Echo" plugin under "Auto Match All Series" section + // The menu item shows the plugin's displayName + const autoMatchEcho = page.locator('[role="menuitem"]:has-text("Echo")').first(); + if ((await autoMatchEcho.count()) > 0) { + await autoMatchEcho.click(); + await page.waitForTimeout(2000); + + // Capture success notification + await captureScreenshot(page, "plugins/library-auto-match-success"); + } else { + console.log(" ⚠️ Auto Match Echo option not found in menu"); + // Close menu + await page.keyboard.press("Escape"); + } + + console.log(" ✓ Library auto-match screenshots captured"); +} + +/** + * Plugin metrics tab in settings + * Shows plugin performance statistics after plugin usage + */ +async function pluginMetricsScreenshots(page: Page): Promise { + console.log(" 📷 Plugin Metrics Tab"); + + // Navigate to metrics settings + await page.goto("/settings/metrics"); + await waitForPageReady(page); + await page.waitForTimeout(500); + + // Click on Plugins tab + const pluginsTab = page.locator('[role="tab"]:has-text("Plugins")').first(); + if ((await pluginsTab.count()) === 0) { + console.log(" ⚠️ Plugins tab not found in metrics"); + return; + } + + await pluginsTab.click(); + await waitForPageReady(page); + await page.waitForTimeout(500); + + // Capture the plugin metrics overview + await captureScreenshot(page, "settings/metrics-plugins-overview"); + + // Try to expand a plugin row to show details + // Target the Plugins tab panel specifically using aria attributes + const pluginRow = page.locator('[role="tabpanel"][aria-labelledby*="plugins" i] .mantine-Table-tbody .mantine-Table-tr').first(); + if ((await pluginRow.count()) > 0 && (await pluginRow.isVisible())) { + // Scroll the row into view before clicking + await pluginRow.scrollIntoViewIfNeeded(); + await page.waitForTimeout(200); + + await pluginRow.click(); + await page.waitForTimeout(300); + + // Capture with expanded details + await captureScreenshot(page, "settings/metrics-plugins-expanded"); + } else { + console.log(" ⚠️ No plugin rows found in metrics table (empty state or not visible)"); + } + + console.log(" ✓ Plugin metrics screenshots captured"); +} + diff --git a/screenshots/scripts/scenarios/reader.ts b/screenshots/scripts/scenarios/reader.ts index a4dd4fdf..539363e2 100644 --- a/screenshots/scripts/scenarios/reader.ts +++ b/screenshots/scripts/scenarios/reader.ts @@ -16,19 +16,19 @@ const READER_CONFIGS: ReaderConfig[] = [ { format: "cbz", searchPattern: ".cbz", - prefix: "20-reader-comic", + prefix: "reader/comic", label: "Comic (CBZ)", }, { format: "epub", searchPattern: ".epub", - prefix: "21-reader-epub", + prefix: "reader/epub", label: "EPUB", }, { format: "pdf", searchPattern: ".pdf", - prefix: "22-reader-pdf", + prefix: "reader/pdf", label: "PDF", }, ]; diff --git a/screenshots/scripts/scenarios/settings.ts b/screenshots/scripts/scenarios/settings.ts index 9b6030d0..2b241c51 100644 --- a/screenshots/scripts/scenarios/settings.ts +++ b/screenshots/scripts/scenarios/settings.ts @@ -11,16 +11,16 @@ export async function run(page: Page, _context: BrowserContext): Promise { // Define all settings pages to capture const settingsPages = [ - { path: "/settings/server", name: "30-settings-server", label: "Server Settings" }, - { path: "/settings/tasks", name: "31-settings-tasks", label: "Tasks" }, - { path: "/settings/metrics", name: "32-settings-metrics", label: "Metrics" }, - { path: "/settings/users", name: "33-settings-users", label: "User Management" }, - { path: "/settings/sharing-tags", name: "34-settings-sharing-tags", label: "Sharing Tags" }, - { path: "/settings/duplicates", name: "35-settings-duplicates", label: "Duplicates" }, - { path: "/settings/book-errors", name: "36-settings-book-errors", label: "Book Errors" }, - { path: "/settings/cleanup", name: "37-settings-cleanup", label: "Thumbnail Cleanup" }, - { path: "/settings/pdf-cache", name: "38-settings-pdf-cache", label: "PDF Cache" }, - { path: "/settings/profile", name: "39-settings-profile", label: "Profile" }, + { path: "/settings/server", name: "settings/server", label: "Server Settings" }, + { path: "/settings/tasks", name: "settings/tasks", label: "Tasks" }, + { path: "/settings/metrics", name: "settings/metrics", label: "Metrics" }, + { path: "/settings/users", name: "settings/users", label: "User Management" }, + { path: "/settings/sharing-tags", name: "settings/sharing-tags", label: "Sharing Tags" }, + { path: "/settings/duplicates", name: "settings/duplicates", label: "Duplicates" }, + { path: "/settings/book-errors", name: "settings/book-errors", label: "Book Errors" }, + { path: "/settings/cleanup", name: "settings/cleanup", label: "Thumbnail Cleanup" }, + { path: "/settings/pdf-cache", name: "settings/pdf-cache", label: "PDF Cache" }, + { path: "/settings/profile", name: "settings/profile", label: "Profile" }, ]; for (const { path, name, label } of settingsPages) { @@ -53,7 +53,7 @@ export async function run(page: Page, _context: BrowserContext): Promise { await page.waitForSelector('.mantine-Modal-content, [role="dialog"]', { state: "visible", timeout: 5000 }); // Capture the template selection modal - await captureScreenshot(page, "30-settings-server-custom-metadata-templates"); + await captureScreenshot(page, "settings/server-custom-metadata-templates"); // Click on a template card to select it (first Card element in the modal Grid) // The cards are rendered inside a Grid within the Modal @@ -85,7 +85,7 @@ export async function run(page: Page, _context: BrowserContext): Promise { await page.waitForTimeout(300); // Capture with template applied (shows editor with template code and preview) - await captureScreenshot(page, "30-settings-server-custom-metadata"); + await captureScreenshot(page, "settings/server-custom-metadata"); } else { console.log(" ⚠️ Custom Metadata tab not found"); } @@ -100,7 +100,7 @@ export async function run(page: Page, _context: BrowserContext): Promise { await tasksTab.click(); await waitForPageReady(page); await page.waitForTimeout(500); - await captureScreenshot(page, "32-settings-metrics-tasks"); + await captureScreenshot(page, "settings/metrics-tasks"); } else { console.log(" ⚠️ Task Performance tab not found"); } @@ -120,7 +120,7 @@ export async function run(page: Page, _context: BrowserContext): Promise { await apiKeysTab.click(); await waitForPageReady(page); await page.waitForTimeout(300); - await captureScreenshot(page, "40-settings-profile-api-keys"); + await captureScreenshot(page, "settings/profile-api-keys"); } // Try to capture Preferences tab @@ -129,6 +129,6 @@ export async function run(page: Page, _context: BrowserContext): Promise { await preferencesTab.click(); await waitForPageReady(page); await page.waitForTimeout(300); - await captureScreenshot(page, "41-settings-profile-preferences"); + await captureScreenshot(page, "settings/profile-preferences"); } } diff --git a/screenshots/scripts/scenarios/setup.ts b/screenshots/scripts/scenarios/setup.ts index 3e03fba6..541a34b0 100644 --- a/screenshots/scripts/scenarios/setup.ts +++ b/screenshots/scripts/scenarios/setup.ts @@ -24,7 +24,7 @@ export async function run(page: Page, _context: BrowserContext): Promise { await waitForPageReady(page); // Capture empty form - await captureScreenshot(page, "01-setup-wizard-step1-empty"); + await captureScreenshot(page, "setup/wizard-step1-empty"); // Fill in admin credentials await page.fill('input[placeholder="admin"]', config.admin.username); @@ -36,7 +36,7 @@ export async function run(page: Page, _context: BrowserContext): Promise { await page.waitForTimeout(300); // Capture filled form - await captureScreenshot(page, "02-setup-wizard-step1-filled"); + await captureScreenshot(page, "setup/wizard-step1-filled"); // Submit the form await page.click('button[type="submit"]:has-text("Create Admin User")'); @@ -58,7 +58,7 @@ export async function run(page: Page, _context: BrowserContext): Promise { } // Capture with skip checked (settings hidden) - await captureScreenshot(page, "03-setup-wizard-step2-skip"); + await captureScreenshot(page, "setup/wizard-step2-skip"); // Now uncheck the skip switch to show settings if (skipLabel) { @@ -67,14 +67,14 @@ export async function run(page: Page, _context: BrowserContext): Promise { } // Capture with basic settings visible - await captureScreenshot(page, "04-setup-wizard-step2-basic-settings"); + await captureScreenshot(page, "setup/wizard-step2-basic-settings"); // Click "Show Advanced Settings" to expand const advancedButton = await page.$('button:has-text("Show Advanced Settings")'); if (advancedButton) { await advancedButton.click(); await page.waitForTimeout(500); // Wait for collapse animation - await captureScreenshot(page, "05-setup-wizard-step2-advanced-settings", { fullPage: true }); + await captureScreenshot(page, "setup/wizard-step2-advanced-settings", { fullPage: true }); } // Check the skip option and complete setup @@ -99,5 +99,5 @@ export async function run(page: Page, _context: BrowserContext): Promise { // Capture post-setup dashboard console.log(" 📝 Post-setup: Dashboard"); - await captureScreenshot(page, "06-setup-complete-dashboard"); + await captureScreenshot(page, "setup/complete-dashboard"); } diff --git a/screenshots/scripts/utils/screenshot.ts b/screenshots/scripts/utils/screenshot.ts index 39336e0a..21740f39 100644 --- a/screenshots/scripts/utils/screenshot.ts +++ b/screenshots/scripts/utils/screenshot.ts @@ -14,19 +14,19 @@ export interface ScreenshotOptions { const capturedScreenshots: string[] = []; /** - * Ensure the output directory exists + * Ensure a directory exists, creating it if necessary */ -async function ensureOutputDir(): Promise { - const outputDir = path.resolve(config.outputDir); - if (!existsSync(outputDir)) { - await mkdir(outputDir, { recursive: true }); +async function ensureDir(dirPath: string): Promise { + const resolvedPath = path.resolve(dirPath); + if (!existsSync(resolvedPath)) { + await mkdir(resolvedPath, { recursive: true }); } } /** * Capture a screenshot with consistent naming * @param page - Playwright page instance - * @param name - Screenshot name (without extension) + * @param name - Screenshot name (can include subdirectory, e.g., "setup/wizard-step1") * @param options - Screenshot options * @returns Path to the saved screenshot */ @@ -35,14 +35,16 @@ export async function captureScreenshot( name: string, options: ScreenshotOptions = {} ): Promise { - await ensureOutputDir(); - // Wait for any toast notifications to disappear before capturing await waitForToastsToDisappear(page); const filename = `${name}.png`; const filepath = path.join(config.outputDir, filename); + // Ensure the directory exists (handles subdirectories like "setup/", "reader/", etc.) + const dir = path.dirname(filepath); + await ensureDir(dir); + await page.screenshot({ path: filepath, fullPage: options.fullPage ?? false, diff --git a/screenshots/tsconfig.json b/screenshots/tsconfig.json index 93169847..f140813c 100644 --- a/screenshots/tsconfig.json +++ b/screenshots/tsconfig.json @@ -1,16 +1,15 @@ { "compilerOptions": { "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", + "module": "NodeNext", + "moduleResolution": "NodeNext", "esModuleInterop": true, "strict": true, "skipLibCheck": true, "resolveJsonModule": true, "declaration": false, "outDir": "./dist", - "rootDir": ".", - "types": ["node"] + "rootDir": "." }, "include": ["scripts/**/*.ts", "playwright.config.ts"], "exclude": ["node_modules", "dist", "output"] diff --git a/src/api/docs.rs b/src/api/docs.rs index 72ca42b0..c62fcaa1 100644 --- a/src/api/docs.rs +++ b/src/api/docs.rs @@ -160,7 +160,9 @@ The following paths are exempt from rate limiting: v1::handlers::list_series, v1::handlers::search_series, v1::handlers::list_series_filtered, + v1::handlers::list_series_alphabetical_groups, v1::handlers::get_series, + v1::handlers::patch_series, v1::handlers::get_series_books, v1::handlers::purge_series_deleted_books, v1::handlers::get_series_thumbnail, @@ -242,6 +244,7 @@ The following paths are exempt from rate limiting: v1::handlers::list_books, v1::handlers::list_books_filtered, v1::handlers::get_book, + v1::handlers::patch_book, v1::handlers::get_adjacent_books, v1::handlers::get_book_file, v1::handlers::get_book_thumbnail, @@ -256,13 +259,13 @@ The following paths are exempt from rate limiting: v1::handlers::list_recently_read_books, v1::handlers::list_library_recently_read_books, v1::handlers::list_books_with_errors, - v1::handlers::list_library_books_with_errors, - v1::handlers::list_series_books_with_errors, - v1::handlers::list_books_with_errors_v2, v1::handlers::retry_book_errors, v1::handlers::retry_all_book_errors, v1::handlers::replace_book_metadata, v1::handlers::patch_book_metadata, + v1::handlers::get_book_metadata_locks, + v1::handlers::update_book_metadata_locks, + v1::handlers::upload_book_cover, // Page endpoints v1::handlers::get_page_image, @@ -277,6 +280,14 @@ The following paths are exempt from rate limiting: v1::handlers::mark_series_as_read, v1::handlers::mark_series_as_unread, + // Bulk operations endpoints + v1::handlers::bulk_mark_books_as_read, + v1::handlers::bulk_mark_books_as_unread, + v1::handlers::bulk_analyze_books, + v1::handlers::bulk_mark_series_as_read, + v1::handlers::bulk_mark_series_as_unread, + v1::handlers::bulk_analyze_series, + // User endpoints v1::handlers::list_users, v1::handlers::create_user, @@ -293,6 +304,7 @@ The following paths are exempt from rate limiting: // Metrics endpoints v1::handlers::get_inventory_metrics, + v1::handlers::get_plugin_metrics, v1::handlers::task_metrics::get_task_metrics, v1::handlers::task_metrics::get_task_metrics_history, v1::handlers::task_metrics::trigger_metrics_cleanup, @@ -319,10 +331,14 @@ The following paths are exempt from rate limiting: v1::handlers::task_queue::get_task_stats, v1::handlers::task_queue::purge_old_tasks, v1::handlers::task_queue::nuke_all_tasks, - v1::handlers::task_queue::generate_thumbnails, - v1::handlers::task_queue::generate_library_thumbnails, - v1::handlers::task_queue::generate_series_thumbnails, + // Book thumbnail endpoints + v1::handlers::task_queue::generate_book_thumbnails, v1::handlers::task_queue::generate_book_thumbnail, + v1::handlers::task_queue::generate_library_book_thumbnails, + // Series thumbnail endpoints + v1::handlers::task_queue::generate_series_thumbnails, + v1::handlers::task_queue::generate_series_thumbnail, + v1::handlers::task_queue::generate_library_series_thumbnails, // Filesystem endpoints v1::handlers::browse_filesystem, @@ -338,6 +354,29 @@ The following paths are exempt from rate limiting: v1::handlers::settings::reset_setting, v1::handlers::settings::get_setting_history, + // Plugins endpoints + v1::handlers::plugins::list_plugins, + v1::handlers::plugins::create_plugin, + v1::handlers::plugins::get_plugin, + v1::handlers::plugins::update_plugin, + v1::handlers::plugins::delete_plugin, + v1::handlers::plugins::enable_plugin, + v1::handlers::plugins::disable_plugin, + v1::handlers::plugins::test_plugin, + v1::handlers::plugins::get_plugin_health, + v1::handlers::plugins::reset_plugin_failures, + v1::handlers::plugins::get_plugin_failures, + + // Plugin Actions endpoints + v1::handlers::plugin_actions::get_plugin_actions, + v1::handlers::plugin_actions::execute_plugin, + v1::handlers::plugin_actions::preview_series_metadata, + v1::handlers::plugin_actions::apply_series_metadata, + v1::handlers::plugin_actions::auto_match_series_metadata, + v1::handlers::plugin_actions::enqueue_auto_match_task, + v1::handlers::plugin_actions::enqueue_bulk_auto_match_tasks, + v1::handlers::plugin_actions::enqueue_library_auto_match_tasks, + // Sharing Tags endpoints v1::handlers::sharing_tags::list_sharing_tags, v1::handlers::sharing_tags::get_sharing_tag, @@ -457,6 +496,9 @@ The following paths are exempt from rate limiting: v1::dto::SearchSeriesRequest, v1::dto::SeriesListRequest, v1::dto::SeriesCondition, + v1::dto::AlphabeticalGroupDto, + v1::dto::PatchSeriesRequest, + v1::dto::SeriesUpdateResponse, v1::dto::BookListRequest, v1::dto::BookCondition, v1::dto::FieldOperator, @@ -540,6 +582,9 @@ The following paths are exempt from rate limiting: v1::dto::FullBookListResponse, v1::dto::BookFullMetadata, v1::dto::BookMetadataLocks, + v1::dto::UpdateBookMetadataLocksRequest, + v1::dto::PatchBookRequest, + v1::dto::BookUpdateResponse, v1::dto::AdjacentBooksResponse, v1::dto::BookMetadataDto, v1::dto::ReplaceBookMetadataRequest, @@ -570,6 +615,12 @@ The following paths are exempt from rate limiting: v1::dto::MetricsDto, v1::dto::LibraryMetricsDto, + // Plugin Metrics DTOs + v1::dto::PluginMetricsResponse, + v1::dto::PluginMetricsSummaryDto, + v1::dto::PluginMetricsDto, + v1::dto::PluginMethodMetricsDto, + // Task Metrics DTOs v1::dto::TaskMetricsResponse, v1::dto::TaskMetricsSummaryDto, @@ -592,6 +643,13 @@ The following paths are exempt from rate limiting: v1::dto::ReadProgressListResponse, v1::dto::MarkReadResponse, + // Bulk operations DTOs + v1::dto::BulkBooksRequest, + v1::dto::BulkAnalyzeBooksRequest, + v1::dto::BulkSeriesRequest, + v1::dto::BulkAnalyzeSeriesRequest, + v1::dto::BulkAnalyzeResponse, + // Filesystem DTOs v1::handlers::filesystem::FileSystemEntry, v1::handlers::filesystem::BrowseResponse, @@ -606,13 +664,53 @@ The following paths are exempt from rate limiting: v1::dto::SettingHistoryDto, v1::dto::ListSettingsQuery, + // Plugin DTOs + v1::dto::PluginDto, + v1::dto::PluginsListResponse, + v1::dto::CreatePluginRequest, + v1::dto::UpdatePluginRequest, + v1::dto::EnvVarDto, + v1::dto::PluginManifestDto, + v1::dto::PluginCapabilitiesDto, + v1::dto::CredentialFieldDto, + v1::dto::PluginTestResult, + v1::dto::PluginStatusResponse, + v1::dto::PluginHealthDto, + v1::dto::PluginHealthResponse, + v1::dto::PluginFailureDto, + v1::dto::PluginFailuresResponse, + + // Plugin Actions DTOs + v1::dto::PluginActionDto, + v1::dto::PluginActionsResponse, + v1::dto::ExecutePluginRequest, + v1::dto::ExecutePluginResponse, + v1::dto::PluginSearchResultDto, + v1::dto::SearchResultPreviewDto, + v1::dto::PluginSearchResponse, + v1::dto::MetadataPreviewRequest, + v1::dto::MetadataPreviewResponse, + v1::dto::MetadataFieldPreview, + v1::dto::FieldApplyStatus, + v1::dto::PreviewSummary, + v1::dto::MetadataApplyRequest, + v1::dto::MetadataApplyResponse, + v1::dto::SkippedField, + v1::dto::MetadataAutoMatchRequest, + v1::dto::MetadataAutoMatchResponse, + v1::dto::EnqueueAutoMatchRequest, + v1::dto::EnqueueAutoMatchResponse, + v1::dto::EnqueueBulkAutoMatchRequest, + v1::dto::EnqueueLibraryAutoMatchRequest, + // Task Queue DTOs v1::handlers::task_queue::CreateTaskRequest, v1::handlers::task_queue::CreateTaskResponse, v1::handlers::task_queue::TaskResponse, v1::handlers::task_queue::PurgeTasksResponse, v1::handlers::task_queue::MessageResponse, - v1::handlers::task_queue::GenerateThumbnailsRequest, + v1::handlers::task_queue::GenerateBookThumbnailsRequest, + v1::handlers::task_queue::GenerateSeriesThumbnailsRequest, v1::handlers::task_queue::ForceRequest, crate::tasks::types::TaskStats, crate::tasks::types::TaskTypeStats, @@ -716,6 +814,8 @@ The following paths are exempt from rate limiting: // System Administration (name = "Admin", description = "Administrative operations (cleanup, maintenance)"), (name = "Settings", description = "Runtime configuration settings (admin only)"), + (name = "Plugins", description = "Admin-managed external plugin processes"), + (name = "Plugin Actions", description = "Plugin action discovery and execution for metadata fetching"), (name = "Metrics", description = "Application metrics and statistics"), (name = "Filesystem", description = "Filesystem browsing for library paths"), (name = "Duplicates", description = "Duplicate book detection and management"), @@ -848,7 +948,7 @@ impl utoipa::Modify for TagGroupsModifier { }, { "name": "Administration", - "tags": ["Admin", "Settings", "Metrics", "Filesystem", "Duplicates", "Sharing Tags"] + "tags": ["Admin", "Settings", "Plugins", "Plugin Actions", "Metrics", "Filesystem", "Duplicates", "Sharing Tags"] }, { "name": "Real-time Events", diff --git a/src/api/error.rs b/src/api/error.rs index d4521373..c40e2bff 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -15,6 +15,8 @@ pub enum ApiError { Conflict(String), /// Resource exists but cannot be processed (e.g., PDF without PDFium) UnprocessableEntity(String), + /// Service is unavailable due to missing configuration or dependencies + ServiceUnavailable(String), Internal(String), } @@ -61,6 +63,11 @@ impl IntoResponse for ApiError { tracing::debug!(error = "UnprocessableEntity", message = %msg, "Unprocessable entity"); (StatusCode::UNPROCESSABLE_ENTITY, "UnprocessableEntity", msg) } + ApiError::ServiceUnavailable(msg) => { + // Log at warn level - server configuration issue + tracing::warn!(error = "ServiceUnavailable", message = %msg, "Service unavailable"); + (StatusCode::SERVICE_UNAVAILABLE, "ServiceUnavailable", msg) + } ApiError::Internal(msg) => { tracing::error!(error = "InternalServerError", message = %msg, "Internal server error"); ( @@ -97,6 +104,17 @@ impl From for ApiError { ); } + // Check for encryption key errors - server misconfiguration + if err_msg.contains("Encryption key not set") + || err_msg.contains("Encryption key must be 32 bytes") + { + return ApiError::ServiceUnavailable( + "Plugin secrets encryption is not configured. \ + Set CODEX_ENCRYPTION_KEY environment variable with a base64-encoded 32-byte key." + .to_string(), + ); + } + ApiError::Internal(err_msg) } } @@ -122,6 +140,17 @@ impl ApiError { ); } + // Check for encryption key errors - server misconfiguration + if err_msg.contains("Encryption key not set") + || err_msg.contains("Encryption key must be 32 bytes") + { + return ApiError::ServiceUnavailable( + "Plugin secrets encryption is not configured. \ + Set CODEX_ENCRYPTION_KEY environment variable with a base64-encoded 32-byte key." + .to_string(), + ); + } + ApiError::Internal(format!("{}: {}", context, err_msg)) } } @@ -157,4 +186,23 @@ mod tests { assert!(json_str.contains("BadRequest")); assert!(json_str.contains("email")); } + + #[test] + fn test_encryption_key_not_set_returns_service_unavailable() { + let err = anyhow::anyhow!("Encryption key not set. Set CODEX_ENCRYPTION_KEY"); + let api_error: ApiError = err.into(); + + assert!(matches!(api_error, ApiError::ServiceUnavailable(_))); + if let ApiError::ServiceUnavailable(msg) = api_error { + assert!(msg.contains("CODEX_ENCRYPTION_KEY")); + } + } + + #[test] + fn test_encryption_key_wrong_size_returns_service_unavailable() { + let err = anyhow::anyhow!("Encryption key must be 32 bytes (256 bits), got 16 bytes"); + let api_error: ApiError = err.into(); + + assert!(matches!(api_error, ApiError::ServiceUnavailable(_))); + } } diff --git a/src/api/extractors/auth.rs b/src/api/extractors/auth.rs index d81f41e9..93233fd1 100644 --- a/src/api/extractors/auth.rs +++ b/src/api/extractors/auth.rs @@ -203,6 +203,12 @@ pub struct AppState { /// Rate limiter service for API rate limiting /// None when rate limiting is disabled in config pub rate_limiter_service: Option>, + /// Plugin manager for coordinating external plugin processes + /// Manages plugin lifecycle, spawning, and request routing + pub plugin_manager: Arc, + /// Plugin metrics service for collecting plugin performance data + /// Always available (in-memory only, no persistence) + pub plugin_metrics_service: Arc, } // Legacy alias for backwards compatibility during transition diff --git a/src/api/permissions.rs b/src/api/permissions.rs index ad42bd54..5257a7b0 100644 --- a/src/api/permissions.rs +++ b/src/api/permissions.rs @@ -108,6 +108,9 @@ pub enum Permission { TasksRead, TasksWrite, + // Plugins (admin configuration) + PluginsManage, + // System SystemHealth, SystemAdmin, @@ -136,6 +139,7 @@ impl Permission { Permission::ApiKeysDelete => "api-keys:delete", Permission::TasksRead => "tasks:read", Permission::TasksWrite => "tasks:write", + Permission::PluginsManage => "plugins:manage", Permission::SystemHealth => "system:health", Permission::SystemAdmin => "system:admin", } @@ -165,6 +169,7 @@ impl FromStr for Permission { "api-keys:delete" => Ok(Permission::ApiKeysDelete), "tasks:read" => Ok(Permission::TasksRead), "tasks:write" => Ok(Permission::TasksWrite), + "plugins:manage" => Ok(Permission::PluginsManage), "system:health" => Ok(Permission::SystemHealth), "system:admin" => Ok(Permission::SystemAdmin), _ => Err(format!("Unknown permission: {}", s)), @@ -249,6 +254,7 @@ lazy_static::lazy_static! { /// Admin can do everything, including: /// - Delete libraries /// - Manage users + /// - Manage plugins /// - System administration pub static ref ADMIN_PERMISSIONS: HashSet = { let mut set = MAINTAINER_PERMISSIONS.clone(); @@ -258,6 +264,8 @@ lazy_static::lazy_static! { set.insert(Permission::UsersRead); set.insert(Permission::UsersWrite); set.insert(Permission::UsersDelete); + // Plugins (configuration) + set.insert(Permission::PluginsManage); // System admin set.insert(Permission::SystemAdmin); set @@ -274,6 +282,7 @@ mod tests { fn test_permission_as_str() { assert_eq!(Permission::LibrariesRead.as_str(), "libraries:read"); assert_eq!(Permission::BooksWrite.as_str(), "books:write"); + assert_eq!(Permission::PluginsManage.as_str(), "plugins:manage"); assert_eq!(Permission::SystemAdmin.as_str(), "system:admin"); } @@ -287,6 +296,10 @@ mod tests { Permission::from_str("books:write").unwrap(), Permission::BooksWrite ); + assert_eq!( + Permission::from_str("plugins:manage").unwrap(), + Permission::PluginsManage + ); assert!(Permission::from_str("invalid:permission").is_err()); } @@ -395,10 +408,12 @@ mod tests { assert!(ADMIN_PERMISSIONS.contains(&Permission::UsersRead)); assert!(ADMIN_PERMISSIONS.contains(&Permission::UsersWrite)); assert!(ADMIN_PERMISSIONS.contains(&Permission::UsersDelete)); + // Admin has plugin management + assert!(ADMIN_PERMISSIONS.contains(&Permission::PluginsManage)); // Admin has system admin assert!(ADMIN_PERMISSIONS.contains(&Permission::SystemAdmin)); - assert_eq!(ADMIN_PERMISSIONS.len(), 20); // All permissions + assert_eq!(ADMIN_PERMISSIONS.len(), 21); // All permissions } // ============== UserRole tests ============== diff --git a/src/api/routes/v1/dto/metrics.rs b/src/api/routes/v1/dto/metrics.rs index 20c4104a..d75909ec 100644 --- a/src/api/routes/v1/dto/metrics.rs +++ b/src/api/routes/v1/dto/metrics.rs @@ -1,5 +1,8 @@ +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use utoipa::ToSchema; +use uuid::Uuid; /// Application metrics response #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -59,3 +62,137 @@ pub struct LibraryMetricsDto { #[schema(example = "15728640000")] pub total_size: i64, } + +// ============================================================ +// Plugin Metrics DTOs +// ============================================================ + +/// Plugin metrics response - current performance statistics for all plugins +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct PluginMetricsResponse { + /// When the metrics were last updated + #[schema(example = "2026-01-30T12:00:00Z")] + pub updated_at: DateTime, + + /// Overall summary statistics + pub summary: PluginMetricsSummaryDto, + + /// Per-plugin breakdown + pub plugins: Vec, +} + +/// Summary metrics across all plugins +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct PluginMetricsSummaryDto { + /// Total number of registered plugins + #[schema(example = 3)] + pub total_plugins: u64, + + /// Number of healthy plugins + #[schema(example = 2)] + pub healthy_plugins: u64, + + /// Number of degraded plugins + #[schema(example = 1)] + pub degraded_plugins: u64, + + /// Number of unhealthy plugins + #[schema(example = 0)] + pub unhealthy_plugins: u64, + + /// Total requests made across all plugins + #[schema(example = 1500)] + pub total_requests: u64, + + /// Total successful requests + #[schema(example = 1400)] + pub total_success: u64, + + /// Total failed requests + #[schema(example = 100)] + pub total_failed: u64, + + /// Total rate limit rejections + #[schema(example = 5)] + pub total_rate_limit_rejections: u64, +} + +/// Metrics for a single plugin +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct PluginMetricsDto { + /// Plugin ID + #[schema(example = "550e8400-e29b-41d4-a716-446655440000")] + pub plugin_id: Uuid, + + /// Plugin name + #[schema(example = "AniList Provider")] + pub plugin_name: String, + + /// Total requests made + #[schema(example = 500)] + pub requests_total: u64, + + /// Successful requests + #[schema(example = 480)] + pub requests_success: u64, + + /// Failed requests + #[schema(example = 20)] + pub requests_failed: u64, + + /// Average request duration in milliseconds + #[schema(example = 250.5)] + pub avg_duration_ms: f64, + + /// Number of rate limit rejections + #[schema(example = 2)] + pub rate_limit_rejections: u64, + + /// Error rate as percentage + #[schema(example = 4.0)] + pub error_rate_pct: f64, + + /// Last successful request timestamp + #[serde(skip_serializing_if = "Option::is_none")] + pub last_success: Option>, + + /// Last failure timestamp + #[serde(skip_serializing_if = "Option::is_none")] + pub last_failure: Option>, + + /// Current health status + #[schema(example = "healthy")] + pub health_status: String, + + /// Per-method breakdown + #[serde(skip_serializing_if = "Option::is_none")] + pub by_method: Option>, + + /// Failure counts by error code + #[serde(skip_serializing_if = "Option::is_none")] + pub failure_counts: Option>, +} + +/// Metrics breakdown by method for a plugin +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct PluginMethodMetricsDto { + /// Method name + #[schema(example = "search")] + pub method: String, + + /// Total requests for this method + #[schema(example = 200)] + pub requests_total: u64, + + /// Successful requests + #[schema(example = 195)] + pub requests_success: u64, + + /// Failed requests + #[schema(example = 5)] + pub requests_failed: u64, + + /// Average duration in milliseconds + #[schema(example = 180.5)] + pub avg_duration_ms: f64, +} diff --git a/src/api/routes/v1/dto/mod.rs b/src/api/routes/v1/dto/mod.rs index a449c384..cbab7839 100644 --- a/src/api/routes/v1/dto/mod.rs +++ b/src/api/routes/v1/dto/mod.rs @@ -15,6 +15,7 @@ pub mod metrics; pub mod page; pub mod patch; pub mod pdf_cache; +pub mod plugins; pub mod read_progress; pub mod scan; pub mod series; @@ -37,6 +38,7 @@ pub use library::*; pub use metrics::*; pub use page::*; pub use pdf_cache::*; +pub use plugins::*; pub use read_progress::*; pub use scan::*; pub use series::*; diff --git a/src/api/routes/v1/dto/plugins.rs b/src/api/routes/v1/dto/plugins.rs new file mode 100644 index 00000000..28865ad8 --- /dev/null +++ b/src/api/routes/v1/dto/plugins.rs @@ -0,0 +1,1304 @@ +//! Plugin DTOs +//! +//! Data Transfer Objects for the Plugin API, enabling CRUD operations +//! for admin-configured external metadata provider plugins. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::db::entities::plugin_failures; +use crate::db::entities::plugins::{self, PluginPermission}; +use crate::db::repositories::PluginsRepository; +use crate::services::plugin::protocol::{ + CredentialField, MetadataContentType, PluginCapabilities, PluginScope, +}; + +// ============================================================================= +// Plugin Response DTOs +// ============================================================================= + +/// A plugin (credentials are never exposed) +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginDto { + /// Plugin ID + #[schema(example = "550e8400-e29b-41d4-a716-446655440000")] + pub id: Uuid, + + /// Unique identifier (e.g., "mangabaka") + #[schema(example = "mangabaka")] + pub name: String, + + /// Human-readable display name + #[schema(example = "MangaBaka")] + pub display_name: String, + + /// Description of the plugin + #[schema(example = "Fetch manga metadata from MangaBaka (MangaUpdates)")] + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Plugin type: "system" (admin-configured) or "user" (per-user instances) + #[schema(example = "system")] + pub plugin_type: String, + + /// Command to spawn the plugin + #[schema(example = "node")] + pub command: String, + + /// Command arguments + #[schema(example = json!(["/opt/codex/plugins/mangabaka/dist/index.js"]))] + pub args: Vec, + + /// Additional environment variables (non-sensitive only) + #[schema(example = json!({"LOG_LEVEL": "info"}))] + pub env: serde_json::Value, + + /// Working directory for the plugin process + #[serde(skip_serializing_if = "Option::is_none")] + pub working_directory: Option, + + /// RBAC permissions for metadata writes + #[schema(example = json!(["metadata:write:summary", "metadata:write:genres"]))] + pub permissions: Vec, + + /// Scopes where plugin can be invoked + #[schema(example = json!(["series:detail", "series:bulk"]))] + pub scopes: Vec, + + /// Library IDs this plugin applies to (empty = all libraries) + #[schema(example = json!([]))] + pub library_ids: Vec, + + /// Whether credentials have been set (actual credentials are never returned) + #[schema(example = true)] + pub has_credentials: bool, + + /// How credentials are delivered to the plugin + #[schema(example = "env")] + pub credential_delivery: String, + + /// Plugin-specific configuration + #[schema(example = json!({"rate_limit": 60}))] + pub config: serde_json::Value, + + /// Cached manifest from plugin (if available) + #[serde(skip_serializing_if = "Option::is_none")] + pub manifest: Option, + + /// Whether the plugin is enabled + #[schema(example = true)] + pub enabled: bool, + + /// Health status: unknown, healthy, degraded, unhealthy, disabled + #[schema(example = "healthy")] + pub health_status: String, + + /// Number of consecutive failures + #[schema(example = 0)] + pub failure_count: i32, + + /// When the last failure occurred + #[serde(skip_serializing_if = "Option::is_none")] + pub last_failure_at: Option>, + + /// When the last successful operation occurred + #[serde(skip_serializing_if = "Option::is_none")] + pub last_success_at: Option>, + + /// Reason the plugin was disabled + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled_reason: Option, + + /// Rate limit in requests per minute (None = no limit) + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = 60)] + pub rate_limit_requests_per_minute: Option, + + /// When the plugin was created + pub created_at: DateTime, + + /// When the plugin was last updated + pub updated_at: DateTime, +} + +impl From for PluginDto { + fn from(model: plugins::Model) -> Self { + let has_credentials = PluginsRepository::has_credentials(&model); + let args = model.args_vec(); + let permissions: Vec = model + .permissions_vec() + .into_iter() + .map(|p| p.to_string()) + .collect(); + let scopes: Vec = model + .scopes_vec() + .into_iter() + .map(|s| scope_to_string(&s)) + .collect(); + let library_ids = model.library_ids_vec(); + + // Parse manifest if available + let manifest = model.cached_manifest().map(PluginManifestDto::from); + + Self { + id: model.id, + name: model.name, + display_name: model.display_name, + description: model.description, + plugin_type: model.plugin_type, + command: model.command, + args, + env: model.env, + working_directory: model.working_directory, + permissions, + scopes, + library_ids, + has_credentials, + credential_delivery: model.credential_delivery, + config: model.config, + manifest, + enabled: model.enabled, + health_status: model.health_status, + failure_count: model.failure_count, + last_failure_at: model.last_failure_at, + last_success_at: model.last_success_at, + disabled_reason: model.disabled_reason, + rate_limit_requests_per_minute: model.rate_limit_requests_per_minute, + created_at: model.created_at, + updated_at: model.updated_at, + } + } +} + +/// Plugin manifest from the plugin itself +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginManifestDto { + /// Unique identifier + pub name: String, + /// Display name for UI + pub display_name: String, + /// Semantic version + pub version: String, + /// Description + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Author + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, + /// Homepage URL + #[serde(skip_serializing_if = "Option::is_none")] + pub homepage: Option, + /// Protocol version + pub protocol_version: String, + /// Plugin capabilities + pub capabilities: PluginCapabilitiesDto, + /// Supported content types + pub content_types: Vec, + /// Required credentials + #[serde(default)] + pub required_credentials: Vec, + /// Supported scopes + #[serde(default)] + pub scopes: Vec, +} + +impl From for PluginManifestDto { + fn from(m: crate::services::plugin::protocol::PluginManifest) -> Self { + // Derive content types from capabilities + let content_types: Vec = m + .capabilities + .metadata_provider + .iter() + .map(content_type_to_string) + .collect(); + + // Derive scopes from capabilities (series metadata provider gets series scopes) + let scopes: Vec = if m.capabilities.can_provide_series_metadata() { + PluginScope::series_scopes() + .into_iter() + .map(|s| scope_to_string(&s)) + .collect() + } else { + vec![] + }; + + Self { + name: m.name, + display_name: m.display_name, + version: m.version, + description: m.description, + author: m.author, + homepage: m.homepage, + protocol_version: m.protocol_version, + capabilities: PluginCapabilitiesDto::from(m.capabilities), + content_types, + required_credentials: m + .required_credentials + .into_iter() + .map(CredentialFieldDto::from) + .collect(), + scopes, + } + } +} + +/// Plugin capabilities +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginCapabilitiesDto { + /// Content types this plugin can provide metadata for (e.g., ["series", "book"]) + #[serde(default)] + pub metadata_provider: Vec, + /// Can sync user reading progress + #[serde(default)] + pub user_sync_provider: bool, +} + +impl From for PluginCapabilitiesDto { + fn from(c: PluginCapabilities) -> Self { + Self { + metadata_provider: c + .metadata_provider + .iter() + .map(content_type_to_string) + .collect(), + user_sync_provider: c.user_sync_provider, + } + } +} + +/// Credential field definition +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CredentialFieldDto { + /// Credential key (e.g., "api_key") + pub key: String, + /// Display label (e.g., "API Key") + pub label: String, + /// Description for the user + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Whether this credential is required + #[serde(default)] + pub required: bool, + /// Whether to mask the value in UI + #[serde(default)] + pub sensitive: bool, + /// Input type for UI + pub credential_type: String, +} + +impl From for CredentialFieldDto { + fn from(f: CredentialField) -> Self { + let credential_type = match f.credential_type { + crate::services::plugin::protocol::CredentialType::String => "string", + crate::services::plugin::protocol::CredentialType::Password => "password", + crate::services::plugin::protocol::CredentialType::OAuth => "oauth", + }; + Self { + key: f.key, + label: f.label, + description: f.description, + required: f.required, + sensitive: f.sensitive, + credential_type: credential_type.to_string(), + } + } +} + +/// Response containing a list of plugins +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginsListResponse { + /// List of plugins + pub plugins: Vec, + /// Total count + pub total: usize, +} + +// ============================================================================= +// Plugin Request DTOs +// ============================================================================= + +/// Request to create a new plugin +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreatePluginRequest { + /// Unique identifier (alphanumeric with underscores) + #[schema(example = "mangabaka")] + pub name: String, + + /// Human-readable display name + #[schema(example = "MangaBaka")] + pub display_name: String, + + /// Description of the plugin + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = "Fetch manga metadata from MangaBaka (MangaUpdates)")] + pub description: Option, + + /// Plugin type: "system" (default) or "user" + #[serde(default = "default_plugin_type")] + #[schema(example = "system")] + pub plugin_type: String, + + /// Command to spawn the plugin + #[schema(example = "node")] + pub command: String, + + /// Command arguments + #[serde(default)] + #[schema(example = json!(["/opt/codex/plugins/mangabaka/dist/index.js"]))] + pub args: Vec, + + /// Additional environment variables + #[serde(default)] + #[schema(example = json!({"LOG_LEVEL": "info"}))] + pub env: Vec, + + /// Working directory for the plugin process + #[serde(skip_serializing_if = "Option::is_none")] + pub working_directory: Option, + + /// RBAC permissions for metadata writes + #[serde(default)] + #[schema(example = json!(["metadata:write:summary", "metadata:write:genres"]))] + pub permissions: Vec, + + /// Scopes where plugin can be invoked + #[serde(default)] + #[schema(example = json!(["series:detail", "series:bulk"]))] + pub scopes: Vec, + + /// Library IDs this plugin applies to (empty = all libraries) + #[serde(default)] + #[schema(example = json!([]))] + pub library_ids: Vec, + + /// Credentials (will be encrypted before storage) + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = json!({"api_key": "your-api-key"}))] + pub credentials: Option, + + /// How credentials are delivered to the plugin: "env", "init_message", or "both" + #[serde(default = "default_credential_delivery")] + #[schema(example = "env")] + pub credential_delivery: String, + + /// Plugin-specific configuration + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = json!({"rate_limit": 60}))] + pub config: Option, + + /// Whether to enable immediately + #[serde(default)] + #[schema(example = false)] + pub enabled: bool, + + /// Rate limit in requests per minute (default: 60, None = no limit) + #[serde(default = "default_rate_limit")] + #[schema(example = 60)] + pub rate_limit_requests_per_minute: Option, +} + +fn default_rate_limit() -> Option { + Some(60) +} + +/// Environment variable key-value pair +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct EnvVarDto { + pub key: String, + pub value: String, +} + +fn default_plugin_type() -> String { + "system".to_string() +} + +fn default_credential_delivery() -> String { + "env".to_string() +} + +/// Request to update a plugin +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdatePluginRequest { + /// Updated display name + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = "MangaBaka v2")] + pub display_name: Option, + + /// Updated description + #[serde(default)] + pub description: Option>, + + /// Updated command + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option, + + /// Updated command arguments + #[serde(skip_serializing_if = "Option::is_none")] + pub args: Option>, + + /// Updated environment variables + #[serde(skip_serializing_if = "Option::is_none")] + pub env: Option>, + + /// Updated working directory + #[serde(default)] + pub working_directory: Option>, + + /// Updated permissions + #[serde(skip_serializing_if = "Option::is_none")] + pub permissions: Option>, + + /// Updated scopes + #[serde(skip_serializing_if = "Option::is_none")] + pub scopes: Option>, + + /// Updated library IDs (empty = all libraries) + #[serde(skip_serializing_if = "Option::is_none")] + pub library_ids: Option>, + + /// Updated credentials (set to null to clear) + #[serde(default)] + pub credentials: Option, + + /// Updated credential delivery method + #[serde(skip_serializing_if = "Option::is_none")] + pub credential_delivery: Option, + + /// Updated configuration + #[serde(skip_serializing_if = "Option::is_none")] + pub config: Option, + + /// Updated rate limit in requests per minute (Some(None) = remove limit) + #[serde(default)] + #[schema(example = 60)] + pub rate_limit_requests_per_minute: Option>, +} + +// ============================================================================= +// Plugin Action Response DTOs +// ============================================================================= + +/// Response from testing a plugin connection +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginTestResult { + /// Whether the test was successful + #[schema(example = true)] + pub success: bool, + + /// Test result message + #[schema(example = "Successfully connected to plugin")] + pub message: String, + + /// Response latency in milliseconds + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = 150)] + pub latency_ms: Option, + + /// Plugin manifest (if connection succeeded) + #[serde(skip_serializing_if = "Option::is_none")] + pub manifest: Option, +} + +/// Response after enabling or disabling a plugin +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginStatusResponse { + /// The updated plugin + pub plugin: PluginDto, + + /// Status change message + #[schema(example = "Plugin enabled successfully")] + pub message: String, + + /// Whether a health check was performed + #[serde(default)] + #[schema(example = true)] + pub health_check_performed: bool, + + /// Health check passed (None if not performed) + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = true)] + pub health_check_passed: Option, + + /// Health check latency in milliseconds (None if not performed) + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = 150)] + pub health_check_latency_ms: Option, + + /// Health check error message (None if passed or not performed) + #[serde(skip_serializing_if = "Option::is_none")] + pub health_check_error: Option, +} + +/// Plugin health information +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginHealthDto { + /// Plugin ID + pub plugin_id: Uuid, + + /// Plugin name + pub name: String, + + /// Current health status + pub health_status: String, + + /// Whether the plugin is enabled + pub enabled: bool, + + /// Number of consecutive failures + pub failure_count: i32, + + /// When the last failure occurred + #[serde(skip_serializing_if = "Option::is_none")] + pub last_failure_at: Option>, + + /// When the last successful operation occurred + #[serde(skip_serializing_if = "Option::is_none")] + pub last_success_at: Option>, + + /// Reason the plugin was disabled + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled_reason: Option, +} + +impl From for PluginHealthDto { + fn from(model: plugins::Model) -> Self { + Self { + plugin_id: model.id, + name: model.name, + health_status: model.health_status, + enabled: model.enabled, + failure_count: model.failure_count, + last_failure_at: model.last_failure_at, + last_success_at: model.last_success_at, + disabled_reason: model.disabled_reason, + } + } +} + +/// Response containing plugin health history/summary +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginHealthResponse { + /// Plugin health information + pub health: PluginHealthDto, +} + +// ============================================================================= +// Plugin Failure DTOs +// ============================================================================= + +/// A single plugin failure event +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginFailureDto { + /// Failure ID + pub id: Uuid, + + /// Human-readable error message + #[schema(example = "Connection timeout after 30s")] + pub error_message: String, + + /// Error code for categorization + #[schema(example = "TIMEOUT")] + #[serde(skip_serializing_if = "Option::is_none")] + pub error_code: Option, + + /// Which method failed + #[schema(example = "metadata/search")] + #[serde(skip_serializing_if = "Option::is_none")] + pub method: Option, + + /// Additional context (parameters, stack trace, etc.) + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, + + /// Sanitized summary of request parameters (sensitive fields redacted) + #[schema(example = "query: \"One Piece\", limit: 10")] + #[serde(skip_serializing_if = "Option::is_none")] + pub request_summary: Option, + + /// When the failure occurred + pub occurred_at: DateTime, +} + +impl From for PluginFailureDto { + fn from(model: plugin_failures::Model) -> Self { + Self { + id: model.id, + error_message: model.error_message, + error_code: model.error_code, + method: model.method, + context: model.context, + request_summary: model.request_summary, + occurred_at: model.occurred_at, + } + } +} + +/// Response containing plugin failure history +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginFailuresResponse { + /// List of failure events + pub failures: Vec, + + /// Total number of failures (for pagination) + pub total: u64, + + /// Number of failures within the current time window + pub window_failures: u64, + + /// Time window size in seconds + #[schema(example = 3600)] + pub window_seconds: i64, + + /// Threshold for auto-disable + #[schema(example = 3)] + pub threshold: u32, +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/// Convert PluginScope to string +fn scope_to_string(scope: &PluginScope) -> String { + match scope { + PluginScope::SeriesDetail => "series:detail".to_string(), + PluginScope::SeriesBulk => "series:bulk".to_string(), + PluginScope::LibraryDetail => "library:detail".to_string(), + PluginScope::LibraryScan => "library:scan".to_string(), + } +} + +/// Convert MetadataContentType to string +fn content_type_to_string(ct: &MetadataContentType) -> String { + match ct { + MetadataContentType::Series => "series".to_string(), + // TODO: Add Book case when book metadata is implemented + // MetadataContentType::Book => "book".to_string(), + } +} + +/// Parse string to PluginScope +pub fn parse_scope(s: &str) -> Option { + match s { + "series:detail" => Some(PluginScope::SeriesDetail), + "series:bulk" => Some(PluginScope::SeriesBulk), + "library:detail" => Some(PluginScope::LibraryDetail), + "library:scan" => Some(PluginScope::LibraryScan), + _ => None, + } +} + +/// Parse string to PluginPermission +pub fn parse_permission(s: &str) -> Option { + std::str::FromStr::from_str(s).ok() +} + +/// Available plugin permissions for documentation/validation +pub fn available_permissions() -> Vec<&'static str> { + vec![ + "metadata:read", + "metadata:write:title", + "metadata:write:summary", + "metadata:write:genres", + "metadata:write:tags", + "metadata:write:covers", + "metadata:write:ratings", + "metadata:write:links", + "metadata:write:year", + "metadata:write:status", + "metadata:write:publisher", + "metadata:write:age_rating", + "metadata:write:language", + "metadata:write:reading_direction", + "metadata:write:*", + "library:read", + ] +} + +/// Available plugin scopes for documentation/validation +pub fn available_scopes() -> Vec<&'static str> { + vec![ + "series:detail", + "series:bulk", + "library:detail", + "library:scan", + ] +} + +/// Available credential delivery methods +pub fn available_credential_delivery_methods() -> Vec<&'static str> { + vec!["env", "init_message", "both"] +} + +// ============================================================================= +// Plugin Actions DTOs (Phase 4) +// ============================================================================= + +/// A plugin action available for a specific scope +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginActionDto { + /// Plugin ID + pub plugin_id: Uuid, + + /// Plugin name + pub plugin_name: String, + + /// Plugin display name + pub plugin_display_name: String, + + /// Action type (e.g., "metadata_search", "metadata_get") + pub action_type: String, + + /// Human-readable label for the action + pub label: String, + + /// Description of the action + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Icon hint for UI (optional) + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + + /// Library IDs this plugin applies to (empty means all libraries) + /// Used by frontend to filter which plugins show up for each library + #[serde(default)] + pub library_ids: Vec, +} + +/// Response containing available plugin actions for a scope +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginActionsResponse { + /// Available actions grouped by plugin + pub actions: Vec, + + /// The scope these actions are for + pub scope: String, +} + +/// Action for metadata plugins +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum MetadataAction { + /// Search for metadata by query + Search, + /// Get full metadata by external ID + Get, + /// Find best match for a title (auto-match) + Match, +} + +/// Plugin action request - tagged by plugin type +/// +/// Each plugin type has its own set of valid actions. +/// This ensures type safety - you can't call a metadata action on a sync plugin. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum PluginActionRequest { + /// Metadata plugin actions (search, get, match) + Metadata { + /// The metadata action to perform + action: MetadataAction, + /// Content type (series or book) + content_type: MetadataContentType, + /// Action-specific parameters + #[serde(default)] + params: serde_json::Value, + }, + /// Health check (works for any plugin type) + Ping, + // Future: Sync { action: SyncAction, params: serde_json::Value }, +} + +/// Request to execute a plugin action +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ExecutePluginRequest { + /// The action to execute, tagged by plugin type + pub action: PluginActionRequest, +} + +/// Response from executing a plugin method +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ExecutePluginResponse { + /// Whether the execution succeeded + pub success: bool, + + /// Result data (varies by method) + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + + /// Error message if failed + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + + /// Execution time in milliseconds + pub latency_ms: u64, +} + +/// Search result from a plugin +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginSearchResultDto { + /// External ID from the provider + pub external_id: String, + + /// Primary title + pub title: String, + + /// Alternative titles + #[serde(default)] + pub alternate_titles: Vec, + + /// Year of publication + #[serde(skip_serializing_if = "Option::is_none")] + pub year: Option, + + /// Cover image URL + #[serde(skip_serializing_if = "Option::is_none")] + pub cover_url: Option, + + /// Relevance score (0.0-1.0). Optional - if not provided, result order indicates relevance. + #[serde(skip_serializing_if = "Option::is_none")] + pub relevance_score: Option, + + /// Preview data for search results + #[serde(skip_serializing_if = "Option::is_none")] + pub preview: Option, +} + +/// Preview data for search results +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SearchResultPreviewDto { + /// Status string + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + + /// Genres + #[serde(default)] + pub genres: Vec, + + /// Rating + #[serde(skip_serializing_if = "Option::is_none")] + pub rating: Option, + + /// Short description + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +/// Response containing search results from a plugin +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginSearchResponse { + /// Search results + pub results: Vec, + + /// Cursor for next page (if available) + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + + /// Plugin that provided the results + pub plugin_id: Uuid, + + /// Plugin name + pub plugin_name: String, +} + +// ============================================================================= +// Metadata Preview/Apply DTOs (Phase 4) +// ============================================================================= + +/// Status of a field during metadata preview +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum FieldApplyStatus { + /// Field will be applied (different value, no lock, has permission) + WillApply, + /// Field is locked by user + Locked, + /// No permission to write this field + NoPermission, + /// Value is unchanged + Unchanged, + /// Field is not provided by plugin + NotProvided, +} + +/// A single field in the metadata preview +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct MetadataFieldPreview { + /// Field name + pub field: String, + + /// Current value in database + #[serde(skip_serializing_if = "Option::is_none")] + pub current_value: Option, + + /// Proposed value from plugin + #[serde(skip_serializing_if = "Option::is_none")] + pub proposed_value: Option, + + /// Apply status + pub status: FieldApplyStatus, + + /// Human-readable reason for status + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +/// Request to preview metadata from a plugin +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct MetadataPreviewRequest { + /// Plugin ID to fetch metadata from + pub plugin_id: Uuid, + + /// External ID from the plugin's search results + pub external_id: String, +} + +/// Response containing metadata preview +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct MetadataPreviewResponse { + /// Field-by-field preview + pub fields: Vec, + + /// Summary counts + pub summary: PreviewSummary, + + /// Plugin that provided the metadata + pub plugin_id: Uuid, + + /// Plugin name + pub plugin_name: String, + + /// External ID used + pub external_id: String, + + /// External URL (link to provider's page) + #[serde(skip_serializing_if = "Option::is_none")] + pub external_url: Option, +} + +/// Summary of preview results +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PreviewSummary { + /// Number of fields that will be applied + pub will_apply: usize, + + /// Number of fields that are locked + pub locked: usize, + + /// Number of fields with no permission + pub no_permission: usize, + + /// Number of fields that are unchanged + pub unchanged: usize, + + /// Number of fields not provided + pub not_provided: usize, +} + +/// Request to apply metadata from a plugin +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct MetadataApplyRequest { + /// Plugin ID to fetch metadata from + pub plugin_id: Uuid, + + /// External ID from the plugin's search results + pub external_id: String, + + /// Optional list of fields to apply (default: all applicable fields) + #[serde(skip_serializing_if = "Option::is_none")] + pub fields: Option>, +} + +/// Response after applying metadata +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct MetadataApplyResponse { + /// Whether the operation succeeded + pub success: bool, + + /// Fields that were applied + pub applied_fields: Vec, + + /// Fields that were skipped (with reasons) + pub skipped_fields: Vec, + + /// Message + pub message: String, +} + +/// A field that was skipped during apply +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SkippedField { + /// Field name + pub field: String, + + /// Reason for skipping + pub reason: String, +} + +/// Request to auto-match and apply metadata from a plugin +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct MetadataAutoMatchRequest { + /// Plugin ID to use for matching + pub plugin_id: Uuid, + + /// Optional query to use for matching (defaults to series title) + #[serde(skip_serializing_if = "Option::is_none")] + pub query: Option, +} + +/// Response after auto-matching metadata +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct MetadataAutoMatchResponse { + /// Whether the operation succeeded + pub success: bool, + + /// The search result that was matched + #[serde(skip_serializing_if = "Option::is_none")] + pub matched_result: Option, + + /// Fields that were applied + pub applied_fields: Vec, + + /// Fields that were skipped (with reasons) + pub skipped_fields: Vec, + + /// Message + pub message: String, + + /// External URL (link to matched item on provider) + #[serde(skip_serializing_if = "Option::is_none")] + pub external_url: Option, +} + +/// Request to enqueue plugin auto-match task for a single series +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct EnqueueAutoMatchRequest { + /// Plugin ID to use for matching + pub plugin_id: Uuid, +} + +/// Request to enqueue plugin auto-match tasks for multiple series (bulk) +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct EnqueueBulkAutoMatchRequest { + /// Plugin ID to use for matching + pub plugin_id: Uuid, + + /// Series IDs to auto-match + pub series_ids: Vec, +} + +/// Request to enqueue plugin auto-match tasks for all series in a library +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct EnqueueLibraryAutoMatchRequest { + /// Plugin ID to use for matching + pub plugin_id: Uuid, +} + +/// Response after enqueuing auto-match task(s) +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct EnqueueAutoMatchResponse { + /// Whether the operation succeeded + pub success: bool, + + /// Number of tasks enqueued + pub tasks_enqueued: usize, + + /// Task IDs that were created + pub task_ids: Vec, + + /// Message + pub message: String, +} + +// ============================================================================= +// Conversions from Protocol Types +// ============================================================================= + +impl From for PluginSearchResultDto { + fn from(r: crate::services::plugin::protocol::SearchResult) -> Self { + Self { + external_id: r.external_id, + title: r.title, + alternate_titles: r.alternate_titles, + year: r.year, + cover_url: r.cover_url, + relevance_score: r.relevance_score, + preview: r.preview.map(SearchResultPreviewDto::from), + } + } +} + +impl From for SearchResultPreviewDto { + fn from(p: crate::services::plugin::protocol::SearchResultPreview) -> Self { + Self { + status: p.status, + genres: p.genres, + rating: p.rating, + description: p.description, + } + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_create_plugin_request_defaults() { + let json = json!({ + "name": "test", + "displayName": "Test", + "command": "node" + }); + + let request: CreatePluginRequest = serde_json::from_value(json).unwrap(); + assert_eq!(request.name, "test"); + assert_eq!(request.plugin_type, "system"); + assert_eq!(request.credential_delivery, "env"); + assert!(request.args.is_empty()); + assert!(request.permissions.is_empty()); + assert!(request.scopes.is_empty()); + assert!(!request.enabled); + } + + #[test] + fn test_create_plugin_request_full() { + let json = json!({ + "name": "mangabaka", + "displayName": "MangaBaka", + "description": "Manga metadata provider", + "pluginType": "system", + "command": "node", + "args": ["/opt/plugins/mangabaka/dist/index.js"], + "env": [{"key": "LOG_LEVEL", "value": "debug"}], + "permissions": ["metadata:write:summary", "metadata:write:genres"], + "scopes": ["series:detail"], + "credentials": {"api_key": "secret"}, + "credentialDelivery": "both", + "config": {"rate_limit": 60}, + "enabled": true + }); + + let request: CreatePluginRequest = serde_json::from_value(json).unwrap(); + assert_eq!(request.name, "mangabaka"); + assert_eq!(request.args.len(), 1); + assert_eq!(request.env.len(), 1); + assert_eq!(request.env[0].key, "LOG_LEVEL"); + assert_eq!(request.permissions.len(), 2); + assert_eq!(request.scopes.len(), 1); + assert!(request.credentials.is_some()); + assert_eq!(request.credential_delivery, "both"); + assert!(request.enabled); + } + + #[test] + fn test_update_plugin_request_partial() { + let json = json!({ + "displayName": "Updated Name", + "permissions": ["metadata:write:*"] + }); + + let request: UpdatePluginRequest = serde_json::from_value(json).unwrap(); + assert_eq!(request.display_name, Some("Updated Name".to_string())); + assert!(request.description.is_none()); + assert!(request.command.is_none()); + assert_eq!( + request.permissions, + Some(vec!["metadata:write:*".to_string()]) + ); + } + + #[test] + fn test_parse_scope() { + assert_eq!( + parse_scope("series:detail"), + Some(PluginScope::SeriesDetail) + ); + assert_eq!(parse_scope("series:bulk"), Some(PluginScope::SeriesBulk)); + assert_eq!(parse_scope("invalid"), None); + } + + #[test] + fn test_parse_permission() { + assert_eq!( + parse_permission("metadata:write:summary"), + Some(PluginPermission::MetadataWriteSummary) + ); + assert_eq!( + parse_permission("metadata:write:*"), + Some(PluginPermission::MetadataWriteAll) + ); + assert_eq!(parse_permission("invalid"), None); + } + + #[test] + fn test_available_permissions() { + let perms = available_permissions(); + assert!(perms.contains(&"metadata:read")); + assert!(perms.contains(&"metadata:write:*")); + assert!(perms.contains(&"library:read")); + } + + #[test] + fn test_available_scopes() { + let scopes = available_scopes(); + assert!(scopes.contains(&"series:detail")); + assert!(scopes.contains(&"series:bulk")); + assert!(scopes.contains(&"library:scan")); + } + + #[test] + fn test_plugin_test_result_serialization() { + let result = PluginTestResult { + success: true, + message: "Connected successfully".to_string(), + latency_ms: Some(150), + manifest: None, + }; + + let json = serde_json::to_value(&result).unwrap(); + assert_eq!(json["success"], true); + assert_eq!(json["latencyMs"], 150); + } +} diff --git a/src/api/routes/v1/dto/read_progress.rs b/src/api/routes/v1/dto/read_progress.rs index 5c438e4c..41c26e50 100644 --- a/src/api/routes/v1/dto/read_progress.rs +++ b/src/api/routes/v1/dto/read_progress.rs @@ -103,3 +103,66 @@ pub struct MarkReadResponse { #[schema(example = "Marked 5 books as read")] pub message: String, } + +// ============================================================================ +// Bulk Operations DTOs +// ============================================================================ + +/// Request to perform bulk operations on multiple books +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct BulkBooksRequest { + /// List of book IDs to operate on + #[schema(example = json!(["550e8400-e29b-41d4-a716-446655440001", "550e8400-e29b-41d4-a716-446655440002"]))] + pub book_ids: Vec, +} + +/// Request to perform bulk analyze operations on multiple books +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct BulkAnalyzeBooksRequest { + /// List of book IDs to analyze + #[schema(example = json!(["550e8400-e29b-41d4-a716-446655440001", "550e8400-e29b-41d4-a716-446655440002"]))] + pub book_ids: Vec, + + /// Whether to force re-analysis of already analyzed books + #[serde(default)] + #[schema(example = false)] + pub force: bool, +} + +/// Request to perform bulk operations on multiple series +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct BulkSeriesRequest { + /// List of series IDs to operate on + #[schema(example = json!(["550e8400-e29b-41d4-a716-446655440001", "550e8400-e29b-41d4-a716-446655440002"]))] + pub series_ids: Vec, +} + +/// Request to perform bulk analyze operations on multiple series +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct BulkAnalyzeSeriesRequest { + /// List of series IDs to analyze + #[schema(example = json!(["550e8400-e29b-41d4-a716-446655440001", "550e8400-e29b-41d4-a716-446655440002"]))] + pub series_ids: Vec, + + /// Whether to force re-analysis of already analyzed books + #[serde(default)] + #[schema(example = false)] + pub force: bool, +} + +/// Response for bulk analyze operations +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct BulkAnalyzeResponse { + /// Number of analysis tasks enqueued + #[schema(example = 5)] + pub tasks_enqueued: usize, + + /// Message describing the operation + #[schema(example = "Enqueued 5 analysis tasks")] + pub message: String, +} diff --git a/src/api/routes/v1/handlers/books.rs b/src/api/routes/v1/handlers/books.rs index dfdf51fd..176dd70c 100644 --- a/src/api/routes/v1/handlers/books.rs +++ b/src/api/routes/v1/handlers/books.rs @@ -71,28 +71,6 @@ pub struct BookListQuery { pub full: bool, } -/// Query parameters for listing books with analysis errors -#[derive(Debug, Deserialize, utoipa::IntoParams)] -#[serde(rename_all = "camelCase")] -#[into_params(rename_all = "camelCase")] -pub struct BooksWithErrorsQuery { - /// Optional library filter - #[serde(default)] - pub library_id: Option, - - /// Optional series filter - #[serde(default)] - pub series_id: Option, - - /// Page number (1-indexed, minimum 1) - #[serde(default = "default_page")] - pub page: u64, - - /// Number of items per page (max 100, default 50) - #[serde(default = "default_page_size")] - pub page_size: u64, -} - /// Query parameters for getting a single book #[derive(Debug, Deserialize, utoipa::IntoParams)] #[serde(rename_all = "camelCase")] @@ -750,194 +728,6 @@ pub async fn list_books_filtered( } } -/// List books with analysis errors -#[utoipa::path( - get, - path = "/api/v1/books/with-errors", - params(BooksWithErrorsQuery), - responses( - (status = 200, description = "Paginated list of books with analysis errors", body = BookListResponse), - (status = 403, description = "Forbidden"), - ), - security( - ("jwt_bearer" = []), - ("api_key" = []) - ), - tag = "Books" -)] -pub async fn list_books_with_errors( - State(state): State>, - auth: AuthContext, - Query(query): Query, -) -> Result { - require_permission!(auth, Permission::BooksRead)?; - - // Validate and normalize pagination params (1-indexed) - let page = query.page.max(1); - let page_size = if query.page_size == 0 { - default_page_size() - } else { - query.page_size.min(MAX_PAGE_SIZE) - }; - let offset = (page - 1) * page_size; - - // Fetch books with errors - let (books_list, total) = BookRepository::list_with_errors( - &state.db, - query.library_id, - query.series_id, - offset, - page_size, - ) - .await - .map_err(|e| ApiError::Internal(format!("Failed to fetch books with errors: {}", e)))?; - - let dtos = books_to_dtos(&state.db, auth.user_id, books_list).await?; - - // Build pagination links - let total_pages = if page_size == 0 { - 0 - } else { - total.div_ceil(page_size) - }; - let mut link_builder = - PaginationLinkBuilder::new("/api/v1/books/with-errors", page, page_size, total_pages); - if let Some(library_id) = query.library_id { - link_builder = link_builder.with_param("library_id", &library_id.to_string()); - } - if let Some(series_id) = query.series_id { - link_builder = link_builder.with_param("series_id", &series_id.to_string()); - } - - let response = BookListResponse::with_builder(dtos, page, page_size, total, &link_builder); - - Ok(paginated_response(response, &link_builder)) -} - -/// List books with analysis errors in a specific library -#[utoipa::path( - get, - path = "/api/v1/libraries/{library_id}/books/with-errors", - params( - ("library_id" = Uuid, Path, description = "Library ID"), - BooksWithErrorsQuery - ), - responses( - (status = 200, description = "Paginated list of books with analysis errors in library", body = BookListResponse), - (status = 403, description = "Forbidden"), - ), - security( - ("jwt_bearer" = []), - ("api_key" = []) - ), - tag = "Books" -)] -pub async fn list_library_books_with_errors( - State(state): State>, - auth: AuthContext, - Path(library_id): Path, - Query(query): Query, -) -> Result { - require_permission!(auth, Permission::BooksRead)?; - - // Validate and normalize pagination params (1-indexed) - let page = query.page.max(1); - let page_size = if query.page_size == 0 { - default_page_size() - } else { - query.page_size.min(MAX_PAGE_SIZE) - }; - let offset = (page - 1) * page_size; - - let (books_list, total) = - BookRepository::list_with_errors(&state.db, Some(library_id), None, offset, page_size) - .await - .map_err(|e| { - ApiError::Internal(format!("Failed to fetch library books with errors: {}", e)) - })?; - - let dtos = books_to_dtos(&state.db, auth.user_id, books_list).await?; - - // Build pagination links - let total_pages = if page_size == 0 { - 0 - } else { - total.div_ceil(page_size) - }; - let link_builder = PaginationLinkBuilder::new( - &format!("/api/v1/libraries/{}/books/with-errors", library_id), - page, - page_size, - total_pages, - ); - - let response = BookListResponse::with_builder(dtos, page, page_size, total, &link_builder); - - Ok(paginated_response(response, &link_builder)) -} - -/// List books with analysis errors in a specific series -#[utoipa::path( - get, - path = "/api/v1/series/{series_id}/books/with-errors", - params( - ("series_id" = Uuid, Path, description = "Series ID"), - BooksWithErrorsQuery - ), - responses( - (status = 200, description = "Paginated list of books with analysis errors in series", body = BookListResponse), - (status = 403, description = "Forbidden"), - ), - security( - ("jwt_bearer" = []), - ("api_key" = []) - ), - tag = "Books" -)] -pub async fn list_series_books_with_errors( - State(state): State>, - auth: AuthContext, - Path(series_id): Path, - Query(query): Query, -) -> Result { - require_permission!(auth, Permission::BooksRead)?; - - // Validate and normalize pagination params (1-indexed) - let page = query.page.max(1); - let page_size = if query.page_size == 0 { - default_page_size() - } else { - query.page_size.min(MAX_PAGE_SIZE) - }; - let offset = (page - 1) * page_size; - - let (books_list, total) = - BookRepository::list_with_errors(&state.db, None, Some(series_id), offset, page_size) - .await - .map_err(|e| { - ApiError::Internal(format!("Failed to fetch series books with errors: {}", e)) - })?; - - let dtos = books_to_dtos(&state.db, auth.user_id, books_list).await?; - - // Build pagination links - let total_pages = if page_size == 0 { - 0 - } else { - total.div_ceil(page_size) - }; - let link_builder = PaginationLinkBuilder::new( - &format!("/api/v1/series/{}/books/with-errors", series_id), - page, - page_size, - total_pages, - ); - - let response = BookListResponse::with_builder(dtos, page, page_size, total, &link_builder); - - Ok(paginated_response(response, &link_builder)) -} - /// Get book by ID #[utoipa::path( get, @@ -2852,7 +2642,7 @@ pub async fn upload_book_cover( } // ============================================================================ -// Book Error Endpoints (v2 - Enhanced with grouping and retry) +// Book Error Endpoints (Enhanced with grouping and retry) // ============================================================================ use super::super::dto::{ @@ -2863,11 +2653,11 @@ use crate::db::entities::book_error::{parse_analysis_errors, BookErrorType}; use crate::db::repositories::TaskRepository; use crate::tasks::types::TaskType; -/// Query parameters for v2 books with errors endpoint +/// Query parameters for listing books with analysis errors #[derive(Debug, Deserialize, utoipa::IntoParams)] #[serde(rename_all = "camelCase")] #[into_params(rename_all = "camelCase")] -pub struct BooksWithErrorsQueryV2 { +pub struct BooksWithErrorsQuery { /// Optional library filter #[serde(default)] pub library_id: Option, @@ -2889,15 +2679,15 @@ pub struct BooksWithErrorsQueryV2 { pub page_size: u64, } -/// List books with errors (v2 - grouped by error type) +/// List books with errors (grouped by error type) /// /// Returns books with errors grouped by error type, with counts and pagination. -/// This enhanced endpoint provides detailed error information including error +/// This endpoint provides detailed error information including error /// types, messages, and timestamps. #[utoipa::path( get, path = "/api/v1/books/errors", - params(BooksWithErrorsQueryV2), + params(BooksWithErrorsQuery), responses( (status = 200, description = "Books with errors grouped by type", body = BooksWithErrorsResponse, example = json!({ @@ -2922,10 +2712,10 @@ pub struct BooksWithErrorsQueryV2 { ), tag = "Books" )] -pub async fn list_books_with_errors_v2( +pub async fn list_books_with_errors( State(state): State>, auth: AuthContext, - Query(query): Query, + Query(query): Query, ) -> Result, ApiError> { require_permission!(auth, Permission::BooksRead)?; @@ -2946,7 +2736,7 @@ pub async fn list_books_with_errors_v2( let error_type_filter = query.error_type.map(|t| t.into()); // Fetch books with errors (convert to 0-indexed for repository) - let (books_with_errors, total) = BookRepository::list_with_errors_v2( + let (books_with_errors, total) = BookRepository::list_with_errors( &state.db, query.library_id, query.series_id, @@ -3191,7 +2981,7 @@ pub async fn retry_all_book_errors( // Fetch all books with errors (unpaginated - we need all for bulk retry) // Use a large page size to get all results - let (books_with_errors, _) = BookRepository::list_with_errors_v2( + let (books_with_errors, _) = BookRepository::list_with_errors( &state.db, request.library_id, None, // No series filter for bulk retry diff --git a/src/api/routes/v1/handlers/bulk.rs b/src/api/routes/v1/handlers/bulk.rs new file mode 100644 index 00000000..c225ba54 --- /dev/null +++ b/src/api/routes/v1/handlers/bulk.rs @@ -0,0 +1,420 @@ +//! Bulk operations handlers +//! +//! Handlers for bulk mark read/unread and analyze operations on books and series. + +use super::super::dto::{ + BulkAnalyzeBooksRequest, BulkAnalyzeResponse, BulkAnalyzeSeriesRequest, BulkBooksRequest, + BulkSeriesRequest, MarkReadResponse, +}; +use crate::api::{error::ApiError, extractors::AuthContext, permissions::Permission, AppState}; +use crate::db::repositories::{ + BookRepository, ReadProgressRepository, SeriesRepository, TaskRepository, +}; +use crate::require_permission; +use crate::tasks::types::TaskType; +use axum::{extract::State, Json}; +use std::sync::Arc; +use uuid::Uuid; + +// ============================================================================ +// Books Bulk Handlers +// ============================================================================ + +/// Bulk mark multiple books as read +/// +/// Marks all specified books as read for the authenticated user. +/// Books that don't exist are silently skipped. +#[utoipa::path( + post, + path = "/api/v1/books/bulk/read", + request_body = BulkBooksRequest, + responses( + (status = 200, description = "Books marked as read", body = MarkReadResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Bulk Operations" +)] +pub async fn bulk_mark_books_as_read( + State(state): State>, + auth: AuthContext, + Json(request): Json, +) -> Result, ApiError> { + require_permission!(auth, Permission::BooksRead)?; + + if request.book_ids.is_empty() { + return Ok(Json(MarkReadResponse { + count: 0, + message: "No books specified".to_string(), + })); + } + + // Get book data (id, page_count) for all valid books + let mut book_data: Vec<(Uuid, i32)> = Vec::new(); + for book_id in &request.book_ids { + if let Ok(Some(book)) = BookRepository::get_by_id(&state.db, *book_id).await { + book_data.push((book.id, book.page_count)); + } + } + + if book_data.is_empty() { + return Ok(Json(MarkReadResponse { + count: 0, + message: "No valid books found".to_string(), + })); + } + + // Mark all books as read + let count = ReadProgressRepository::mark_series_as_read(&state.db, auth.user_id, book_data) + .await + .map_err(|e| ApiError::Internal(format!("Failed to mark books as read: {}", e)))?; + + Ok(Json(MarkReadResponse { + count, + message: format!("Marked {} books as read", count), + })) +} + +/// Bulk mark multiple books as unread +/// +/// Marks all specified books as unread for the authenticated user. +/// Books that don't exist or have no progress are silently skipped. +#[utoipa::path( + post, + path = "/api/v1/books/bulk/unread", + request_body = BulkBooksRequest, + responses( + (status = 200, description = "Books marked as unread", body = MarkReadResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Bulk Operations" +)] +pub async fn bulk_mark_books_as_unread( + State(state): State>, + auth: AuthContext, + Json(request): Json, +) -> Result, ApiError> { + require_permission!(auth, Permission::BooksRead)?; + + if request.book_ids.is_empty() { + return Ok(Json(MarkReadResponse { + count: 0, + message: "No books specified".to_string(), + })); + } + + // Mark all books as unread (delete progress records) + let count = + ReadProgressRepository::mark_series_as_unread(&state.db, auth.user_id, request.book_ids) + .await + .map_err(|e| ApiError::Internal(format!("Failed to mark books as unread: {}", e)))?; + + Ok(Json(MarkReadResponse { + count: count as usize, + message: format!("Marked {} books as unread", count), + })) +} + +/// Bulk analyze multiple books +/// +/// Enqueues analysis tasks for all specified books. +/// Books that don't exist are silently skipped. +#[utoipa::path( + post, + path = "/api/v1/books/bulk/analyze", + request_body = BulkAnalyzeBooksRequest, + responses( + (status = 200, description = "Analysis tasks enqueued", body = BulkAnalyzeResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Bulk Operations" +)] +pub async fn bulk_analyze_books( + State(state): State>, + auth: AuthContext, + Json(request): Json, +) -> Result, ApiError> { + require_permission!(auth, Permission::BooksWrite)?; + + if request.book_ids.is_empty() { + return Ok(Json(BulkAnalyzeResponse { + tasks_enqueued: 0, + message: "No books specified".to_string(), + })); + } + + let mut enqueued = 0; + for book_id in &request.book_ids { + // Verify book exists + if BookRepository::get_by_id(&state.db, *book_id) + .await + .ok() + .flatten() + .is_none() + { + continue; + } + + // Enqueue AnalyzeBook task + let task_type = TaskType::AnalyzeBook { + book_id: *book_id, + force: request.force, + }; + + match TaskRepository::enqueue(&state.db, task_type, 0, None).await { + Ok(_) => enqueued += 1, + Err(e) => { + tracing::error!("Failed to enqueue task for book {}: {}", book_id, e); + } + } + } + + Ok(Json(BulkAnalyzeResponse { + tasks_enqueued: enqueued, + message: format!("Enqueued {} analysis tasks", enqueued), + })) +} + +// ============================================================================ +// Series Bulk Handlers +// ============================================================================ + +/// Bulk mark multiple series as read +/// +/// Marks all books in the specified series as read for the authenticated user. +/// Series that don't exist are silently skipped. +#[utoipa::path( + post, + path = "/api/v1/series/bulk/read", + request_body = BulkSeriesRequest, + responses( + (status = 200, description = "Series marked as read", body = MarkReadResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Bulk Operations" +)] +pub async fn bulk_mark_series_as_read( + State(state): State>, + auth: AuthContext, + Json(request): Json, +) -> Result, ApiError> { + require_permission!(auth, Permission::BooksRead)?; + + if request.series_ids.is_empty() { + return Ok(Json(MarkReadResponse { + count: 0, + message: "No series specified".to_string(), + })); + } + + let mut total_count = 0; + + for series_id in &request.series_ids { + // Verify series exists + if SeriesRepository::get_by_id(&state.db, *series_id) + .await + .ok() + .flatten() + .is_none() + { + continue; + } + + // Get all books in the series + let books = match BookRepository::list_by_series(&state.db, *series_id, false).await { + Ok(books) => books, + Err(_) => continue, + }; + + if books.is_empty() { + continue; + } + + // Create book data for marking as read + let book_data: Vec<(Uuid, i32)> = books + .iter() + .map(|book| (book.id, book.page_count)) + .collect(); + + // Mark all books as read + match ReadProgressRepository::mark_series_as_read(&state.db, auth.user_id, book_data).await + { + Ok(count) => total_count += count, + Err(e) => { + tracing::error!("Failed to mark series {} as read: {}", series_id, e); + } + } + } + + Ok(Json(MarkReadResponse { + count: total_count, + message: format!("Marked {} books as read", total_count), + })) +} + +/// Bulk mark multiple series as unread +/// +/// Marks all books in the specified series as unread for the authenticated user. +/// Series that don't exist are silently skipped. +#[utoipa::path( + post, + path = "/api/v1/series/bulk/unread", + request_body = BulkSeriesRequest, + responses( + (status = 200, description = "Series marked as unread", body = MarkReadResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Bulk Operations" +)] +pub async fn bulk_mark_series_as_unread( + State(state): State>, + auth: AuthContext, + Json(request): Json, +) -> Result, ApiError> { + require_permission!(auth, Permission::BooksRead)?; + + if request.series_ids.is_empty() { + return Ok(Json(MarkReadResponse { + count: 0, + message: "No series specified".to_string(), + })); + } + + let mut total_count: u64 = 0; + + for series_id in &request.series_ids { + // Verify series exists + if SeriesRepository::get_by_id(&state.db, *series_id) + .await + .ok() + .flatten() + .is_none() + { + continue; + } + + // Get all books in the series + let books = match BookRepository::list_by_series(&state.db, *series_id, false).await { + Ok(books) => books, + Err(_) => continue, + }; + + if books.is_empty() { + continue; + } + + let book_ids: Vec = books.iter().map(|book| book.id).collect(); + + // Mark all books as unread + match ReadProgressRepository::mark_series_as_unread(&state.db, auth.user_id, book_ids).await + { + Ok(count) => total_count += count, + Err(e) => { + tracing::error!("Failed to mark series {} as unread: {}", series_id, e); + } + } + } + + Ok(Json(MarkReadResponse { + count: total_count as usize, + message: format!("Marked {} books as unread", total_count), + })) +} + +/// Bulk analyze multiple series +/// +/// Enqueues analysis tasks for all books in the specified series. +/// Series that don't exist are silently skipped. +#[utoipa::path( + post, + path = "/api/v1/series/bulk/analyze", + request_body = BulkAnalyzeSeriesRequest, + responses( + (status = 200, description = "Analysis tasks enqueued", body = BulkAnalyzeResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Bulk Operations" +)] +pub async fn bulk_analyze_series( + State(state): State>, + auth: AuthContext, + Json(request): Json, +) -> Result, ApiError> { + require_permission!(auth, Permission::SeriesWrite)?; + + if request.series_ids.is_empty() { + return Ok(Json(BulkAnalyzeResponse { + tasks_enqueued: 0, + message: "No series specified".to_string(), + })); + } + + let mut enqueued = 0; + + for series_id in &request.series_ids { + // Verify series exists + if SeriesRepository::get_by_id(&state.db, *series_id) + .await + .ok() + .flatten() + .is_none() + { + continue; + } + + // Enqueue AnalyzeSeries task (which will create individual book tasks) + // Note: We enqueue individual book tasks for more granular control + let books = match BookRepository::list_by_series(&state.db, *series_id, false).await { + Ok(books) => books, + Err(_) => continue, + }; + + for book in books { + let task_type = TaskType::AnalyzeBook { + book_id: book.id, + force: request.force, + }; + + match TaskRepository::enqueue(&state.db, task_type, 0, None).await { + Ok(_) => enqueued += 1, + Err(e) => { + tracing::error!("Failed to enqueue task for book {}: {}", book.id, e); + } + } + } + } + + Ok(Json(BulkAnalyzeResponse { + tasks_enqueued: enqueued, + message: format!("Enqueued {} analysis tasks", enqueued), + })) +} diff --git a/src/api/routes/v1/handlers/metrics.rs b/src/api/routes/v1/handlers/metrics.rs index 878067a3..2847bb50 100644 --- a/src/api/routes/v1/handlers/metrics.rs +++ b/src/api/routes/v1/handlers/metrics.rs @@ -1,7 +1,11 @@ use axum::{extract::State, Json}; +use chrono::Utc; use std::sync::Arc; -use super::super::dto::{LibraryMetricsDto, MetricsDto}; +use super::super::dto::{ + LibraryMetricsDto, MetricsDto, PluginMethodMetricsDto, PluginMetricsDto, PluginMetricsResponse, + PluginMetricsSummaryDto, +}; use crate::api::{error::ApiError, extractors::AuthContext, permissions::Permission, AppState}; use crate::db::repositories::MetricsRepository; @@ -109,3 +113,101 @@ pub async fn get_inventory_metrics( libraries, })) } + +/// Get plugin metrics +/// +/// Returns real-time performance statistics for all plugins including: +/// - Summary metrics across all plugins +/// - Per-plugin breakdown with timing, error rates, and health status +/// - Per-method breakdown within each plugin +/// +/// # Permission Required +/// - `libraries:read` or admin status +#[utoipa::path( + get, + path = "/api/v1/metrics/plugins", + responses( + (status = 200, description = "Plugin metrics retrieved successfully", body = PluginMetricsResponse), + (status = 403, description = "Permission denied"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Metrics" +)] +pub async fn get_plugin_metrics( + State(state): State>, + auth: AuthContext, +) -> Result, ApiError> { + auth.require_permission(&Permission::LibrariesRead)?; + + let summary = state.plugin_metrics_service.get_summary().await; + let plugin_snapshots = state.plugin_metrics_service.get_all_metrics().await; + + // Convert snapshots to DTOs + let plugins: Vec = plugin_snapshots + .into_iter() + .map(|snapshot| { + let by_method = if snapshot.by_method.is_empty() { + None + } else { + Some( + snapshot + .by_method + .into_iter() + .map(|(name, m)| { + ( + name, + PluginMethodMetricsDto { + method: m.method, + requests_total: m.requests_total, + requests_success: m.requests_success, + requests_failed: m.requests_failed, + avg_duration_ms: m.avg_duration_ms, + }, + ) + }) + .collect(), + ) + }; + + let failure_counts = if snapshot.failure_counts.is_empty() { + None + } else { + Some(snapshot.failure_counts) + }; + + PluginMetricsDto { + plugin_id: snapshot.plugin_id, + plugin_name: snapshot.plugin_name, + requests_total: snapshot.requests_total, + requests_success: snapshot.requests_success, + requests_failed: snapshot.requests_failed, + avg_duration_ms: snapshot.avg_duration_ms, + rate_limit_rejections: snapshot.rate_limit_rejections, + error_rate_pct: snapshot.error_rate_pct, + last_success: snapshot.last_success, + last_failure: snapshot.last_failure, + health_status: snapshot.health_status.as_str().to_string(), + by_method, + failure_counts, + } + }) + .collect(); + + Ok(Json(PluginMetricsResponse { + updated_at: Utc::now(), + summary: PluginMetricsSummaryDto { + total_plugins: summary.total_plugins, + healthy_plugins: summary.healthy_plugins, + degraded_plugins: summary.degraded_plugins, + unhealthy_plugins: summary.unhealthy_plugins, + total_requests: summary.total_requests, + total_success: summary.total_success, + total_failed: summary.total_failed, + total_rate_limit_rejections: summary.total_rate_limit_rejections, + }, + plugins, + })) +} diff --git a/src/api/routes/v1/handlers/mod.rs b/src/api/routes/v1/handlers/mod.rs index e3d8708e..60c46bb4 100644 --- a/src/api/routes/v1/handlers/mod.rs +++ b/src/api/routes/v1/handlers/mod.rs @@ -42,6 +42,7 @@ pub fn paginated_response(data: T, link_builder: &PaginationLinkBu pub mod api_keys; pub mod auth; pub mod books; +pub mod bulk; pub mod cleanup; pub mod duplicates; pub mod events; @@ -52,6 +53,8 @@ pub mod libraries; pub mod metrics; pub mod pages; pub mod pdf_cache; +pub mod plugin_actions; +pub mod plugins; pub mod read_progress; pub mod scan; pub mod series; @@ -65,6 +68,7 @@ pub mod users; pub use auth::*; pub use books::*; +pub use bulk::*; pub use duplicates::*; pub use events::*; pub use filesystem::*; @@ -75,5 +79,4 @@ pub use pages::*; pub use read_progress::*; pub use scan::*; pub use series::*; -pub use task_queue::*; pub use users::*; diff --git a/src/api/routes/v1/handlers/plugin_actions.rs b/src/api/routes/v1/handlers/plugin_actions.rs new file mode 100644 index 00000000..943e5f0a --- /dev/null +++ b/src/api/routes/v1/handlers/plugin_actions.rs @@ -0,0 +1,1828 @@ +//! Plugin Actions API handlers (Phase 4) +//! +//! Provides endpoints for plugin action discovery and execution: +//! - GET /api/v1/plugins/actions - Get available plugin actions for a scope +//! - POST /api/v1/plugins/:id/execute - Execute a plugin method +//! +//! And metadata operations via plugins: +//! - POST /api/v1/series/:id/metadata/preview - Preview metadata from a plugin +//! - POST /api/v1/series/:id/metadata/apply - Apply metadata from a plugin +//! - POST /api/v1/books/:id/metadata/preview - Preview metadata for a book +//! - POST /api/v1/books/:id/metadata/apply - Apply metadata for a book + +use super::super::dto::{ + parse_scope, EnqueueAutoMatchRequest, EnqueueAutoMatchResponse, EnqueueBulkAutoMatchRequest, + EnqueueLibraryAutoMatchRequest, ExecutePluginRequest, ExecutePluginResponse, FieldApplyStatus, + MetadataAction, MetadataApplyRequest, MetadataApplyResponse, MetadataAutoMatchRequest, + MetadataAutoMatchResponse, MetadataFieldPreview, MetadataPreviewRequest, + MetadataPreviewResponse, PluginActionDto, PluginActionRequest, PluginActionsResponse, + PluginSearchResponse, PluginSearchResultDto, PreviewSummary, SkippedField, +}; +use crate::api::{error::ApiError, extractors::AuthContext, permissions::Permission, AppState}; +use crate::db::entities::plugins::PluginPermission; +use crate::db::repositories::{ + AlternateTitleRepository, ExternalLinkRepository, ExternalRatingRepository, GenreRepository, + LibraryRepository, PluginsRepository, SeriesMetadataRepository, SeriesRepository, + TagRepository, TaskRepository, +}; +use crate::services::metadata::{ApplyOptions, MetadataApplier}; +use crate::services::plugin::protocol::{ + MetadataContentType, MetadataGetParams, MetadataMatchParams, MetadataSearchParams, +}; +use crate::services::plugin::PluginManagerError; +use crate::tasks::types::TaskType; +use axum::{ + extract::{Path, Query, State}, + Json, +}; +use serde::Deserialize; +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Instant; +use utoipa::OpenApi; +use uuid::Uuid; + +#[derive(OpenApi)] +#[openapi( + paths( + get_plugin_actions, + execute_plugin, + preview_series_metadata, + apply_series_metadata, + auto_match_series_metadata, + enqueue_auto_match_task, + enqueue_bulk_auto_match_tasks, + enqueue_library_auto_match_tasks, + ), + components(schemas( + PluginActionDto, + PluginActionsResponse, + MetadataAction, + PluginActionRequest, + ExecutePluginRequest, + ExecutePluginResponse, + PluginSearchResponse, + PluginSearchResultDto, + MetadataPreviewRequest, + MetadataPreviewResponse, + MetadataFieldPreview, + FieldApplyStatus, + PreviewSummary, + MetadataApplyRequest, + MetadataApplyResponse, + SkippedField, + MetadataAutoMatchRequest, + MetadataAutoMatchResponse, + EnqueueAutoMatchRequest, + EnqueueAutoMatchResponse, + EnqueueBulkAutoMatchRequest, + EnqueueLibraryAutoMatchRequest, + )), + tags( + (name = "Plugin Actions", description = "Plugin action discovery and execution") + ) +)] +#[allow(dead_code)] +pub struct PluginActionsApi; + +/// Query parameters for getting plugin actions +#[derive(Debug, Deserialize, utoipa::IntoParams)] +#[serde(rename_all = "camelCase")] +pub struct PluginActionsQuery { + /// Scope to filter actions by (e.g., "series:detail", "series:bulk") + pub scope: String, + + /// Optional library ID to filter plugins by. When provided, only plugins that + /// apply to this library (or all libraries) will be returned. + #[serde(default)] + pub library_id: Option, +} + +/// Get available plugin actions for a scope +/// +/// Returns a list of available plugin actions for the specified scope. +/// This is used by the UI to populate dropdown menus with available plugins. +#[utoipa::path( + get, + path = "/api/v1/plugins/actions", + params(PluginActionsQuery), + responses( + (status = 200, description = "Plugin actions retrieved", body = PluginActionsResponse), + (status = 400, description = "Invalid scope"), + (status = 401, description = "Unauthorized"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Plugin Actions" +)] +pub async fn get_plugin_actions( + State(state): State>, + auth: AuthContext, + Query(query): Query, +) -> Result, ApiError> { + // Require LibrariesRead permission to view plugin actions. + // This prevents users without library access from enumerating plugins. + auth.require_permission(&Permission::LibrariesRead)?; + + // Get user's effective permissions for filtering plugins by capability + let user_permissions = auth.effective_permissions(); + + // Parse and validate scope + let scope = parse_scope(&query.scope).ok_or_else(|| { + ApiError::BadRequest(format!( + "Invalid scope '{}'. Valid scopes: series:detail, series:bulk, library:detail, library:scan", + query.scope + )) + })?; + + // If library_id is provided, verify the library exists + if let Some(library_id) = query.library_id { + let library_exists = LibraryRepository::get_by_id(&state.db, library_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to check library: {}", e)))? + .is_some(); + + if !library_exists { + return Err(ApiError::NotFound("Library not found".to_string())); + } + } + + // Get plugins that support this scope, optionally filtered by library + let plugins = match query.library_id { + Some(library_id) => { + state + .plugin_manager + .plugins_by_scope_and_library(&scope, library_id) + .await + } + None => state.plugin_manager.plugins_by_scope(&scope).await, + }; + + // Build actions list, filtering by user permissions + let mut actions = Vec::new(); + + for plugin in plugins { + // Skip disabled plugins + if !plugin.enabled { + continue; + } + + // Check if plugin has metadata provider capability from its cached manifest + let manifest = match plugin.cached_manifest() { + Some(m) => m, + None => continue, // No manifest = can't determine capabilities + }; + + // Get the content types this plugin supports + let supported_content_types = &manifest.capabilities.metadata_provider; + + // Skip plugins the user doesn't have permission to use + // User needs write permission for at least one of the plugin's content types + if !user_can_use_plugin(supported_content_types, &user_permissions) { + continue; + } + + // Check if plugin can provide series metadata + if manifest.capabilities.can_provide_series_metadata() { + // Add metadata search action + actions.push(PluginActionDto { + plugin_id: plugin.id, + plugin_name: plugin.name.clone(), + plugin_display_name: plugin.display_name.clone(), + action_type: "metadata_search".to_string(), + label: format!("Fetch from {}", plugin.display_name), + description: plugin.description.clone(), + icon: Some("search".to_string()), + library_ids: plugin.library_ids_vec(), + }); + } + } + + Ok(Json(PluginActionsResponse { + actions, + scope: query.scope, + })) +} + +/// Execute a plugin action +/// +/// Invokes a plugin action and returns the result. Actions are typed by plugin type: +/// - `metadata`: search, get, match (requires write permission for the content_type) +/// - `ping`: health check (requires PluginsManage permission) +#[utoipa::path( + post, + path = "/api/v1/plugins/{id}/execute", + params( + ("id" = Uuid, Path, description = "Plugin ID") + ), + request_body = ExecutePluginRequest, + responses( + (status = 200, description = "Action executed", body = ExecutePluginResponse), + (status = 400, description = "Invalid request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Insufficient permission for this action"), + (status = 404, description = "Plugin not found"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Plugin Actions" +)] +pub async fn execute_plugin( + State(state): State>, + auth: AuthContext, + Path(plugin_id): Path, + Json(request): Json, +) -> Result, ApiError> { + let start = Instant::now(); + + // Check permission based on action type + match &request.action { + PluginActionRequest::Metadata { content_type, .. } => { + // Metadata actions require write permission for the content type + let required_permission = permission_for_content_type(content_type); + auth.require_permission(&required_permission)?; + } + PluginActionRequest::Ping => { + // Ping is an admin operation (health check) + auth.require_permission(&Permission::PluginsManage)?; + } + } + + // Get plugin from database to verify it exists + let plugin = PluginsRepository::get_by_id(&state.db, plugin_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get plugin: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Plugin not found".to_string()))?; + + if !plugin.enabled { + return Ok(Json(ExecutePluginResponse { + success: false, + result: None, + error: Some("Plugin is disabled".to_string()), + latency_ms: start.elapsed().as_millis() as u64, + })); + } + + // Execute based on action type + // Backend owns the protocol method strings - frontend only knows about typed actions + match request.action { + PluginActionRequest::Metadata { + action, + content_type, + params, + } => execute_metadata_action(&state, plugin_id, action, content_type, params, start).await, + PluginActionRequest::Ping => match state.plugin_manager.ping(plugin_id).await { + Ok(()) => Ok(Json(ExecutePluginResponse { + success: true, + result: Some(serde_json::json!("pong")), + error: None, + latency_ms: start.elapsed().as_millis() as u64, + })), + Err(e) => Ok(Json(ExecutePluginResponse { + success: false, + result: None, + error: Some(sanitize_plugin_error(&e)), + latency_ms: start.elapsed().as_millis() as u64, + })), + }, + } +} + +/// Execute a metadata plugin action +async fn execute_metadata_action( + state: &Arc, + plugin_id: Uuid, + action: MetadataAction, + content_type: MetadataContentType, + params: serde_json::Value, + start: Instant, +) -> Result, ApiError> { + match (action, content_type) { + (MetadataAction::Search, MetadataContentType::Series) => { + let params: MetadataSearchParams = serde_json::from_value(params) + .map_err(|e| ApiError::BadRequest(format!("Invalid search params: {}", e)))?; + + match state.plugin_manager.search_series(plugin_id, params).await { + Ok(response) => { + let result = serde_json::to_value(&response) + .map_err(|e| ApiError::Internal(format!("Failed to serialize: {}", e)))?; + + Ok(Json(ExecutePluginResponse { + success: true, + result: Some(result), + error: None, + latency_ms: start.elapsed().as_millis() as u64, + })) + } + Err(e) => Ok(Json(ExecutePluginResponse { + success: false, + result: None, + error: Some(sanitize_plugin_error(&e)), + latency_ms: start.elapsed().as_millis() as u64, + })), + } + } + (MetadataAction::Get, MetadataContentType::Series) => { + let params: MetadataGetParams = serde_json::from_value(params) + .map_err(|e| ApiError::BadRequest(format!("Invalid get params: {}", e)))?; + + match state + .plugin_manager + .get_series_metadata(plugin_id, params) + .await + { + Ok(metadata) => { + let result = serde_json::to_value(&metadata) + .map_err(|e| ApiError::Internal(format!("Failed to serialize: {}", e)))?; + + Ok(Json(ExecutePluginResponse { + success: true, + result: Some(result), + error: None, + latency_ms: start.elapsed().as_millis() as u64, + })) + } + Err(e) => Ok(Json(ExecutePluginResponse { + success: false, + result: None, + error: Some(sanitize_plugin_error(&e)), + latency_ms: start.elapsed().as_millis() as u64, + })), + } + } + (MetadataAction::Match, MetadataContentType::Series) => { + let params: MetadataMatchParams = serde_json::from_value(params) + .map_err(|e| ApiError::BadRequest(format!("Invalid match params: {}", e)))?; + + match state.plugin_manager.match_series(plugin_id, params).await { + Ok(result) => { + let result = serde_json::to_value(&result) + .map_err(|e| ApiError::Internal(format!("Failed to serialize: {}", e)))?; + + Ok(Json(ExecutePluginResponse { + success: true, + result: Some(result), + error: None, + latency_ms: start.elapsed().as_millis() as u64, + })) + } + Err(e) => Ok(Json(ExecutePluginResponse { + success: false, + result: None, + error: Some(sanitize_plugin_error(&e)), + latency_ms: start.elapsed().as_millis() as u64, + })), + } + } // Book metadata actions - not yet implemented + // When MetadataContentType::Book is added, these arms will be needed: + // (MetadataAction::Search, MetadataContentType::Book) => { ... } + // (MetadataAction::Get, MetadataContentType::Book) => { ... } + // (MetadataAction::Match, MetadataContentType::Book) => { ... } + } +} + +/// Preview metadata from a plugin for a series +/// +/// Fetches metadata from a plugin and computes a field-by-field diff with the current +/// series metadata, showing which fields will be applied, locked, or denied by RBAC. +#[utoipa::path( + post, + path = "/api/v1/series/{id}/metadata/preview", + params( + ("id" = Uuid, Path, description = "Series ID") + ), + request_body = MetadataPreviewRequest, + responses( + (status = 200, description = "Preview computed", body = MetadataPreviewResponse), + (status = 400, description = "Invalid request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "No permission to edit series"), + (status = 404, description = "Series or plugin not found"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Plugin Actions" +)] +pub async fn preview_series_metadata( + State(state): State>, + auth: AuthContext, + Path(series_id): Path, + Json(request): Json, +) -> Result, ApiError> { + // Check permission to edit series metadata + auth.require_permission(&Permission::SeriesWrite)?; + + // Get the series (verify it exists) + let series = SeriesRepository::get_by_id(&state.db, series_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get series: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Series not found".to_string()))?; + + // Get the plugin + let plugin = PluginsRepository::get_by_id(&state.db, request.plugin_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get plugin: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Plugin not found".to_string()))?; + + if !plugin.enabled { + return Err(ApiError::BadRequest("Plugin is disabled".to_string())); + } + + // Check if plugin applies to this series' library + if !plugin.applies_to_library(series.library_id) { + return Err(ApiError::BadRequest(format!( + "Plugin '{}' is not configured to apply to this series' library", + plugin.display_name + ))); + } + + // Fetch metadata from plugin + let params = MetadataGetParams { + external_id: request.external_id.clone(), + }; + + let plugin_metadata = state + .plugin_manager + .get_series_metadata(request.plugin_id, params) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch metadata from plugin: {}", e)))?; + + // Get current series metadata + let current_metadata = SeriesMetadataRepository::get_by_series_id(&state.db, series_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get current metadata: {}", e)))?; + + // Get current genres, tags, and alternate titles + let current_genres = GenreRepository::get_genres_for_series(&state.db, series_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get genres: {}", e)))?; + let current_tags = TagRepository::get_tags_for_series(&state.db, series_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get tags: {}", e)))?; + let current_alternate_titles = AlternateTitleRepository::get_for_series(&state.db, series_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get alternate titles: {}", e)))?; + + // Get plugin permissions (used via has_permission closure) + let _plugin_permissions = plugin.permissions_vec(); + + // Build field-by-field preview + let mut fields = Vec::new(); + let mut will_apply = 0; + let mut locked = 0; + let mut no_permission = 0; + let mut unchanged = 0; + let mut not_provided = 0; + + // Helper to check permission + let has_permission = |perm: PluginPermission| -> bool { plugin.has_permission(&perm) }; + + // Title + fields.push(build_field_preview( + "title", + current_metadata + .as_ref() + .map(|m| serde_json::json!(m.title.clone())), + plugin_metadata.title.as_ref().map(|v| serde_json::json!(v)), + current_metadata + .as_ref() + .map(|m| m.title_lock) + .unwrap_or(false), + has_permission(PluginPermission::MetadataWriteTitle), + &mut will_apply, + &mut locked, + &mut no_permission, + &mut unchanged, + &mut not_provided, + )); + + // Alternate Titles + let current_alt_titles: Vec = current_alternate_titles + .iter() + .map(|t| serde_json::json!({"label": t.label, "title": t.title})) + .collect(); + fields.push(build_field_preview( + "alternateTitles", + if current_alt_titles.is_empty() { + None + } else { + Some(serde_json::json!(current_alt_titles)) + }, + if plugin_metadata.alternate_titles.is_empty() { + None + } else { + let proposed_alt_titles: Vec = plugin_metadata + .alternate_titles + .iter() + .map(|t| { + serde_json::json!({ + "label": t.title_type.as_deref().unwrap_or("Alternative"), + "title": t.title + }) + }) + .collect(); + Some(serde_json::json!(proposed_alt_titles)) + }, + current_metadata + .as_ref() + .map(|m| m.title_lock) // Use title_lock to control alternate titles too + .unwrap_or(false), + has_permission(PluginPermission::MetadataWriteTitle), // Use title permission + &mut will_apply, + &mut locked, + &mut no_permission, + &mut unchanged, + &mut not_provided, + )); + + // Summary + fields.push(build_field_preview( + "summary", + current_metadata + .as_ref() + .and_then(|m| m.summary.as_ref().map(|v| serde_json::json!(v))), + plugin_metadata + .summary + .as_ref() + .map(|v| serde_json::json!(v)), + current_metadata + .as_ref() + .map(|m| m.summary_lock) + .unwrap_or(false), + has_permission(PluginPermission::MetadataWriteSummary), + &mut will_apply, + &mut locked, + &mut no_permission, + &mut unchanged, + &mut not_provided, + )); + + // Year + fields.push(build_field_preview( + "year", + current_metadata + .as_ref() + .and_then(|m| m.year.map(|v| serde_json::json!(v))), + plugin_metadata.year.map(|v| serde_json::json!(v)), + current_metadata + .as_ref() + .map(|m| m.year_lock) + .unwrap_or(false), + has_permission(PluginPermission::MetadataWriteYear), + &mut will_apply, + &mut locked, + &mut no_permission, + &mut unchanged, + &mut not_provided, + )); + + // Status + fields.push(build_field_preview( + "status", + current_metadata + .as_ref() + .and_then(|m| m.status.as_ref().map(|v| serde_json::json!(v))), + plugin_metadata + .status + .as_ref() + .map(|v| serde_json::json!(v.to_string())), + current_metadata + .as_ref() + .map(|m| m.status_lock) + .unwrap_or(false), + has_permission(PluginPermission::MetadataWriteStatus), + &mut will_apply, + &mut locked, + &mut no_permission, + &mut unchanged, + &mut not_provided, + )); + + // Publisher + fields.push(build_field_preview( + "publisher", + current_metadata + .as_ref() + .and_then(|m| m.publisher.as_ref().map(|v| serde_json::json!(v))), + plugin_metadata + .publisher + .as_ref() + .map(|v| serde_json::json!(v)), + current_metadata + .as_ref() + .map(|m| m.publisher_lock) + .unwrap_or(false), + has_permission(PluginPermission::MetadataWritePublisher), + &mut will_apply, + &mut locked, + &mut no_permission, + &mut unchanged, + &mut not_provided, + )); + + // Genres + let current_genre_names: Vec = current_genres.iter().map(|g| g.name.clone()).collect(); + fields.push(build_field_preview( + "genres", + Some(serde_json::json!(current_genre_names)), + if plugin_metadata.genres.is_empty() { + None + } else { + Some(serde_json::json!(plugin_metadata.genres)) + }, + current_metadata + .as_ref() + .map(|m| m.genres_lock) + .unwrap_or(false), + has_permission(PluginPermission::MetadataWriteGenres), + &mut will_apply, + &mut locked, + &mut no_permission, + &mut unchanged, + &mut not_provided, + )); + + // Tags + let current_tag_names: Vec = current_tags.iter().map(|t| t.name.clone()).collect(); + fields.push(build_field_preview( + "tags", + Some(serde_json::json!(current_tag_names)), + if plugin_metadata.tags.is_empty() { + None + } else { + Some(serde_json::json!(plugin_metadata.tags)) + }, + current_metadata + .as_ref() + .map(|m| m.tags_lock) + .unwrap_or(false), + has_permission(PluginPermission::MetadataWriteTags), + &mut will_apply, + &mut locked, + &mut no_permission, + &mut unchanged, + &mut not_provided, + )); + + // Age Rating + fields.push(build_field_preview( + "ageRating", + current_metadata + .as_ref() + .and_then(|m| m.age_rating.map(|v| serde_json::json!(v))), + plugin_metadata.age_rating.map(|v| serde_json::json!(v)), + current_metadata + .as_ref() + .map(|m| m.age_rating_lock) + .unwrap_or(false), + has_permission(PluginPermission::MetadataWriteAgeRating), + &mut will_apply, + &mut locked, + &mut no_permission, + &mut unchanged, + &mut not_provided, + )); + + // Language + fields.push(build_field_preview( + "language", + current_metadata + .as_ref() + .and_then(|m| m.language.as_ref().map(|v| serde_json::json!(v))), + plugin_metadata + .language + .as_ref() + .map(|v| serde_json::json!(v)), + current_metadata + .as_ref() + .map(|m| m.language_lock) + .unwrap_or(false), + has_permission(PluginPermission::MetadataWriteLanguage), + &mut will_apply, + &mut locked, + &mut no_permission, + &mut unchanged, + &mut not_provided, + )); + + // Reading Direction + fields.push(build_field_preview( + "readingDirection", + current_metadata + .as_ref() + .and_then(|m| m.reading_direction.as_ref().map(|v| serde_json::json!(v))), + plugin_metadata + .reading_direction + .as_ref() + .map(|v| serde_json::json!(v)), + current_metadata + .as_ref() + .map(|m| m.reading_direction_lock) + .unwrap_or(false), + has_permission(PluginPermission::MetadataWriteReadingDirection), + &mut will_apply, + &mut locked, + &mut no_permission, + &mut unchanged, + &mut not_provided, + )); + + // Total Book Count + fields.push(build_field_preview( + "totalBookCount", + current_metadata + .as_ref() + .and_then(|m| m.total_book_count.map(|v| serde_json::json!(v))), + plugin_metadata + .total_book_count + .map(|v| serde_json::json!(v)), + current_metadata + .as_ref() + .map(|m| m.total_book_count_lock) + .unwrap_or(false), + has_permission(PluginPermission::MetadataWriteTotalBookCount), + &mut will_apply, + &mut locked, + &mut no_permission, + &mut unchanged, + &mut not_provided, + )); + + // External Links (preview only - shows what links would be added/updated) + // Get current external links + let current_links = ExternalLinkRepository::get_for_series(&state.db, series_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get external links: {}", e)))?; + let current_link_sources: Vec = current_links + .iter() + .map(|l| l.source_name.clone()) + .collect(); + fields.push(build_field_preview( + "externalLinks", + Some(serde_json::json!(current_link_sources)), + if plugin_metadata.external_links.is_empty() { + None + } else { + let proposed_links: Vec = plugin_metadata + .external_links + .iter() + .map(|l| serde_json::json!({"label": l.label, "url": l.url})) + .collect(); + Some(serde_json::json!(proposed_links)) + }, + false, // Links don't have a lock field + has_permission(PluginPermission::MetadataWriteLinks), + &mut will_apply, + &mut locked, + &mut no_permission, + &mut unchanged, + &mut not_provided, + )); + + // External Rating (primary rating - preview only) + let current_ratings = ExternalRatingRepository::get_for_series(&state.db, series_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get external ratings: {}", e)))?; + let current_rating_info: Option = current_ratings + .iter() + .find(|r| r.source_name == plugin.name.to_lowercase()) + .map(|r| serde_json::json!({"score": r.rating, "source": r.source_name})); + fields.push(build_field_preview( + "rating", + current_rating_info, + plugin_metadata.rating.as_ref().map(|r| { + serde_json::json!({ + "score": r.score, + "voteCount": r.vote_count, + "source": r.source + }) + }), + false, // Ratings don't have a lock field + has_permission(PluginPermission::MetadataWriteRatings), + &mut will_apply, + &mut locked, + &mut no_permission, + &mut unchanged, + &mut not_provided, + )); + + // External Ratings array (multiple sources like AniList, MAL, etc.) + if !plugin_metadata.external_ratings.is_empty() { + // Build current ratings map for comparison + let current_ext_ratings: Vec = current_ratings + .iter() + .map(|r| serde_json::json!({"score": r.rating, "source": r.source_name})) + .collect(); + let proposed_ext_ratings: Vec = plugin_metadata + .external_ratings + .iter() + .map(|r| { + serde_json::json!({ + "score": r.score, + "voteCount": r.vote_count, + "source": r.source + }) + }) + .collect(); + fields.push(build_field_preview( + "externalRatings", + if current_ext_ratings.is_empty() { + None + } else { + Some(serde_json::json!(current_ext_ratings)) + }, + Some(serde_json::json!(proposed_ext_ratings)), + false, // Ratings don't have a lock field + has_permission(PluginPermission::MetadataWriteRatings), + &mut will_apply, + &mut locked, + &mut no_permission, + &mut unchanged, + &mut not_provided, + )); + } + + // Cover URL (preview only - shows if a cover would be downloaded) + fields.push(build_field_preview( + "coverUrl", + None, // We don't show the current cover URL in preview + plugin_metadata + .cover_url + .as_ref() + .map(|v| serde_json::json!(v)), + false, // Covers don't have a lock field + has_permission(PluginPermission::MetadataWriteCovers), + &mut will_apply, + &mut locked, + &mut no_permission, + &mut unchanged, + &mut not_provided, + )); + + Ok(Json(MetadataPreviewResponse { + fields, + summary: PreviewSummary { + will_apply, + locked, + no_permission, + unchanged, + not_provided, + }, + plugin_id: plugin.id, + plugin_name: plugin.display_name, + external_id: request.external_id, + external_url: Some(plugin_metadata.external_url), + })) +} + +/// Apply metadata from a plugin to a series +/// +/// Fetches metadata from a plugin and applies it to the series, respecting +/// RBAC permissions and field locks. +#[utoipa::path( + post, + path = "/api/v1/series/{id}/metadata/apply", + params( + ("id" = Uuid, Path, description = "Series ID") + ), + request_body = MetadataApplyRequest, + responses( + (status = 200, description = "Metadata applied", body = MetadataApplyResponse), + (status = 400, description = "Invalid request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "No permission to edit series"), + (status = 404, description = "Series or plugin not found"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Plugin Actions" +)] +pub async fn apply_series_metadata( + State(state): State>, + auth: AuthContext, + Path(series_id): Path, + Json(request): Json, +) -> Result, ApiError> { + // Check permission to edit series metadata + auth.require_permission(&Permission::SeriesWrite)?; + + // Get the series (verify it exists and get library_id for events) + let series = SeriesRepository::get_by_id(&state.db, series_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get series: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Series not found".to_string()))?; + + // Get the plugin + let plugin = PluginsRepository::get_by_id(&state.db, request.plugin_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get plugin: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Plugin not found".to_string()))?; + + if !plugin.enabled { + return Err(ApiError::BadRequest("Plugin is disabled".to_string())); + } + + // Check if plugin applies to this series' library + if !plugin.applies_to_library(series.library_id) { + return Err(ApiError::BadRequest(format!( + "Plugin '{}' is not configured to apply to this series' library", + plugin.display_name + ))); + } + + // Fetch metadata from plugin + let params = MetadataGetParams { + external_id: request.external_id.clone(), + }; + + let plugin_metadata = state + .plugin_manager + .get_series_metadata(request.plugin_id, params) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch metadata from plugin: {}", e)))?; + + // Get current series metadata + let current_metadata = SeriesMetadataRepository::get_by_series_id(&state.db, series_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get current metadata: {}", e)))?; + + // Build apply options + let options = ApplyOptions { + fields_filter: request.fields.map(|f| f.into_iter().collect()), + thumbnail_service: Some(state.thumbnail_service.clone()), + event_broadcaster: Some(state.event_broadcaster.clone()), + }; + + // Apply metadata using the shared service + let result = MetadataApplier::apply( + &state.db, + series_id, + series.library_id, + &plugin, + &plugin_metadata, + current_metadata.as_ref(), + &options, + ) + .await + .map_err(|e| ApiError::Internal(format!("Failed to apply metadata: {}", e)))?; + + // Convert SkippedField types + let skipped_fields: Vec = result + .skipped_fields + .into_iter() + .map(|sf| SkippedField { + field: sf.field, + reason: sf.reason, + }) + .collect(); + + let message = if result.applied_fields.is_empty() { + "No fields were applied".to_string() + } else { + format!("Applied {} field(s)", result.applied_fields.len()) + }; + + Ok(Json(MetadataApplyResponse { + success: !result.applied_fields.is_empty(), + applied_fields: result.applied_fields, + skipped_fields, + message, + })) +} + +/// Auto-match and apply metadata from a plugin to a series +/// +/// Searches for the series using the plugin's metadata search, picks the best match, +/// and applies the metadata in one step. This is a convenience endpoint for quick +/// metadata updates without user intervention. +#[utoipa::path( + post, + path = "/api/v1/series/{id}/metadata/auto-match", + params( + ("id" = Uuid, Path, description = "Series ID") + ), + request_body = MetadataAutoMatchRequest, + responses( + (status = 200, description = "Auto-match completed", body = MetadataAutoMatchResponse), + (status = 400, description = "Invalid request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "No permission to edit series"), + (status = 404, description = "Series or plugin not found or no match found"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Plugin Actions" +)] +pub async fn auto_match_series_metadata( + State(state): State>, + auth: AuthContext, + Path(series_id): Path, + Json(request): Json, +) -> Result, ApiError> { + // Check permission to edit series metadata + auth.require_permission(&Permission::SeriesWrite)?; + + // Get the series (verify it exists and get its title) + let series = SeriesRepository::get_by_id(&state.db, series_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get series: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Series not found".to_string()))?; + + // Get the current series metadata for title + let series_metadata = SeriesMetadataRepository::get_by_series_id(&state.db, series_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get series metadata: {}", e)))?; + + // Use the provided query or fall back to series title + let search_query = request.query.unwrap_or_else(|| { + series_metadata + .map(|m| m.title) + .unwrap_or_else(|| series.name.clone()) + }); + + // Get the plugin + let plugin = PluginsRepository::get_by_id(&state.db, request.plugin_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get plugin: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Plugin not found".to_string()))?; + + if !plugin.enabled { + return Err(ApiError::BadRequest("Plugin is disabled".to_string())); + } + + // Check if plugin applies to this series' library + if !plugin.applies_to_library(series.library_id) { + return Err(ApiError::BadRequest(format!( + "Plugin '{}' is not configured to apply to this series' library", + plugin.display_name + ))); + } + + // Search for metadata using the plugin + let search_params = MetadataSearchParams { + query: search_query.clone(), + limit: Some(10), // Only need a few results to find the best match + cursor: None, + }; + + let search_response = state + .plugin_manager + .search_series(request.plugin_id, search_params) + .await + .map_err(|e| ApiError::Internal(format!("Failed to search for metadata: {}", e)))?; + + // Check if we got any results + if search_response.results.is_empty() { + return Ok(Json(MetadataAutoMatchResponse { + success: false, + matched_result: None, + applied_fields: vec![], + skipped_fields: vec![], + message: format!("No matches found for '{}'", search_query), + external_url: None, + })); + } + + // Pick the best result - use relevance_score if available, otherwise take first result + // (APIs typically return results in relevance order already) + let best_match = search_response + .results + .into_iter() + .enumerate() + .max_by(|(i, a), (j, b)| { + match (a.relevance_score, b.relevance_score) { + (Some(a_score), Some(b_score)) => a_score + .partial_cmp(&b_score) + .unwrap_or(std::cmp::Ordering::Equal), + // If no scores, prefer earlier results (lower index = higher relevance) + _ => j.cmp(i), + } + }) + .map(|(_, result)| result) + .unwrap(); // Safe: we checked results is non-empty + + let external_id = best_match.external_id.clone(); + let matched_result_dto = PluginSearchResultDto::from(best_match); + + // Fetch full metadata for the best match + let params = MetadataGetParams { + external_id: external_id.clone(), + }; + + let plugin_metadata = state + .plugin_manager + .get_series_metadata(request.plugin_id, params) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch metadata from plugin: {}", e)))?; + + let external_url = plugin_metadata.external_url.clone(); + + // Get current series metadata + let current_metadata = SeriesMetadataRepository::get_by_series_id(&state.db, series_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get current metadata: {}", e)))?; + + // Build apply options (no field filtering for auto-match) + let options = ApplyOptions { + fields_filter: None, // Apply all fields + thumbnail_service: Some(state.thumbnail_service.clone()), + event_broadcaster: Some(state.event_broadcaster.clone()), + }; + + // Apply metadata using the shared service + let result = MetadataApplier::apply( + &state.db, + series_id, + series.library_id, + &plugin, + &plugin_metadata, + current_metadata.as_ref(), + &options, + ) + .await + .map_err(|e| ApiError::Internal(format!("Failed to apply metadata: {}", e)))?; + + // Convert SkippedField types + let skipped_fields: Vec = result + .skipped_fields + .into_iter() + .map(|sf| SkippedField { + field: sf.field, + reason: sf.reason, + }) + .collect(); + + let message = if result.applied_fields.is_empty() { + format!( + "Matched '{}' but no fields were applied", + matched_result_dto.title + ) + } else { + format!( + "Matched '{}' and applied {} field(s)", + matched_result_dto.title, + result.applied_fields.len() + ) + }; + + Ok(Json(MetadataAutoMatchResponse { + success: !result.applied_fields.is_empty(), + matched_result: Some(matched_result_dto), + applied_fields: result.applied_fields, + skipped_fields, + message, + external_url: Some(external_url), + })) +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/// Build a field preview entry +#[allow(clippy::too_many_arguments)] +fn build_field_preview( + field: &str, + current_value: Option, + proposed_value: Option, + is_locked: bool, + has_permission: bool, + will_apply: &mut usize, + locked: &mut usize, + no_permission: &mut usize, + unchanged: &mut usize, + not_provided: &mut usize, +) -> MetadataFieldPreview { + let (status, reason) = if proposed_value.is_none() { + *not_provided += 1; + ( + FieldApplyStatus::NotProvided, + Some("Not provided by plugin".to_string()), + ) + } else if is_locked { + *locked += 1; + ( + FieldApplyStatus::Locked, + Some("Field is locked".to_string()), + ) + } else if !has_permission { + *no_permission += 1; + ( + FieldApplyStatus::NoPermission, + Some("Plugin lacks permission".to_string()), + ) + } else if current_value == proposed_value { + *unchanged += 1; + ( + FieldApplyStatus::Unchanged, + Some("Value unchanged".to_string()), + ) + } else { + *will_apply += 1; + (FieldApplyStatus::WillApply, None) + }; + + MetadataFieldPreview { + field: field.to_string(), + current_value, + proposed_value, + status, + reason, + } +} + +// ============================================================================= +// Permission Helpers for Plugin Visibility +// ============================================================================= + +/// Map a metadata content type to the required permission. +/// +/// This determines what permission a user needs to use a plugin for a given content type: +/// - Series metadata plugins require `SeriesWrite` +/// - Book metadata plugins require `BooksWrite` +/// - Library metadata plugins require `LibrariesWrite` (future) +fn permission_for_content_type(content_type: &MetadataContentType) -> Permission { + match content_type { + MetadataContentType::Series => Permission::SeriesWrite, + // When Book and Library are added: + // MetadataContentType::Book => Permission::BooksWrite, + // MetadataContentType::Library => Permission::LibrariesWrite, + } +} + +/// Check if a user has permission to use a plugin based on its metadata capabilities. +/// +/// A user can use a plugin if they have the required write permission for at least +/// one of the content types the plugin supports. +fn user_can_use_plugin( + plugin_capabilities: &[MetadataContentType], + user_permissions: &HashSet, +) -> bool { + plugin_capabilities + .iter() + .map(permission_for_content_type) + .any(|perm| user_permissions.contains(&perm)) +} + +/// Sanitize plugin error messages for client responses. +/// +/// This prevents exposing internal error details to clients while preserving +/// user-actionable information. The full error is logged server-side. +fn sanitize_plugin_error(error: &PluginManagerError) -> String { + // Log the full error server-side for debugging + tracing::warn!(error = %error, "Plugin operation failed"); + + match error { + // User-actionable errors - return sanitized messages + PluginManagerError::PluginNotFound(id) => format!("Plugin {} not found", id), + PluginManagerError::PluginNotEnabled(id) => format!("Plugin {} is not enabled", id), + PluginManagerError::NoPluginsForScope(_) => { + "No plugins available for this operation".to_string() + } + PluginManagerError::RateLimited { .. } => { + "Plugin rate limit exceeded, please try again later".to_string() + } + + // Nested plugin errors - extract and sanitize + PluginManagerError::Plugin(plugin_error) => sanitize_nested_plugin_error(plugin_error), + + // Internal errors - don't expose details + PluginManagerError::Database(_) | PluginManagerError::Encryption(_) => { + "An internal plugin error occurred".to_string() + } + } +} + +/// Sanitize nested PluginError messages +/// +/// Since the nested error types (PluginError, RpcError) are not part of the public API, +/// we pattern match on the error string to provide user-friendly messages. +fn sanitize_nested_plugin_error(error: &crate::services::plugin::handle::PluginError) -> String { + use crate::services::plugin::handle::PluginError; + use crate::services::plugin::rpc::RpcError; + + match error { + PluginError::NotInitialized => "Plugin is not ready, please try again".to_string(), + PluginError::Disabled { .. } => "Plugin is disabled".to_string(), + PluginError::HealthCheckFailed(_) => "Plugin is temporarily unavailable".to_string(), + PluginError::SpawnFailed(_) => { + "Failed to start plugin, please contact an administrator".to_string() + } + PluginError::InvalidManifest(_) => "Plugin configuration error".to_string(), + + // RPC errors - these may contain more detail + PluginError::Rpc(rpc_error) => match rpc_error { + RpcError::Timeout(_) => "Plugin request timed out, please try again".to_string(), + RpcError::PluginError { code, message, .. } => { + // JSON-RPC errors from the plugin are user-visible + // but we sanitize the data field which may contain internal details + format!("Plugin error ({}): {}", code, message) + } + RpcError::RateLimited { .. } => { + "Plugin rate limit exceeded, please try again later".to_string() + } + RpcError::Cancelled => "Plugin request was cancelled".to_string(), + // User-friendly errors from plugin - pass through the message + RpcError::NotFound(msg) => format!("Not found: {}", msg), + RpcError::AuthFailed(_) => { + "Plugin authentication failed, please check credentials".to_string() + } + RpcError::ApiError(msg) => format!("External API error: {}", msg), + RpcError::ConfigError(_) => "Plugin configuration error".to_string(), + // Internal RPC errors - don't expose details + RpcError::Serialization(_) | RpcError::InvalidResponse(_) | RpcError::Process(_) => { + "Plugin communication error, please try again".to_string() + } + }, + + // Process errors - don't expose command details + PluginError::Process(_) => "Plugin communication error, please try again".to_string(), + } +} + +// ============================================================================= +// Task-based Auto-Match Endpoints (Background Processing) +// ============================================================================= + +/// Maximum number of series that can be enqueued in a single bulk request. +/// This prevents worker queue overload through excessive task creation. +const MAX_BULK_SERIES_COUNT: usize = 100; + +/// Maximum number of series that can be enqueued for a library auto-match. +/// Libraries with more series will be rejected with an error suggesting +/// to use the bulk endpoint in batches instead. +const MAX_LIBRARY_SERIES_COUNT: usize = 1000; + +/// Enqueue a plugin auto-match task for a single series +/// +/// Creates a background task to auto-match metadata for a series using the specified plugin. +/// The task runs asynchronously in a worker process and emits a SeriesMetadataUpdated event +/// when complete. +#[utoipa::path( + post, + path = "/api/v1/series/{id}/metadata/auto-match/task", + params( + ("id" = Uuid, Path, description = "Series ID") + ), + request_body = EnqueueAutoMatchRequest, + responses( + (status = 200, description = "Task enqueued", body = EnqueueAutoMatchResponse), + (status = 400, description = "Invalid request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "No permission to edit series"), + (status = 404, description = "Series or plugin not found"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Plugin Actions" +)] +pub async fn enqueue_auto_match_task( + State(state): State>, + auth: AuthContext, + Path(series_id): Path, + Json(request): Json, +) -> Result, ApiError> { + // Check permission to edit series metadata + auth.require_permission(&Permission::SeriesWrite)?; + + // Verify series exists + let series = SeriesRepository::get_by_id(&state.db, series_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get series: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Series not found".to_string()))?; + + // Verify plugin exists and is enabled + let plugin = PluginsRepository::get_by_id(&state.db, request.plugin_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get plugin: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Plugin not found".to_string()))?; + + if !plugin.enabled { + return Err(ApiError::BadRequest("Plugin is disabled".to_string())); + } + + // Check if plugin applies to this series' library + if !plugin.applies_to_library(series.library_id) { + return Err(ApiError::BadRequest(format!( + "Plugin '{}' is not configured to apply to this series' library", + plugin.display_name + ))); + } + + // Create the task + let task_type = TaskType::PluginAutoMatch { + series_id, + plugin_id: request.plugin_id, + source_scope: Some("series:detail".to_string()), + }; + + let task_id = TaskRepository::enqueue(&state.db, task_type, 0, None) + .await + .map_err(|e| ApiError::Internal(format!("Failed to enqueue task: {}", e)))?; + + Ok(Json(EnqueueAutoMatchResponse { + success: true, + tasks_enqueued: 1, + task_ids: vec![task_id], + message: "Auto-match task enqueued".to_string(), + })) +} + +/// Enqueue plugin auto-match tasks for multiple series (bulk operation) +/// +/// Creates background tasks to auto-match metadata for multiple series using the specified plugin. +/// Each series gets its own task that runs asynchronously in a worker process. +#[utoipa::path( + post, + path = "/api/v1/series/metadata/auto-match/task/bulk", + request_body = EnqueueBulkAutoMatchRequest, + responses( + (status = 200, description = "Tasks enqueued", body = EnqueueAutoMatchResponse), + (status = 400, description = "Invalid request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "No permission to edit series"), + (status = 404, description = "Plugin not found"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Plugin Actions" +)] +pub async fn enqueue_bulk_auto_match_tasks( + State(state): State>, + auth: AuthContext, + Json(request): Json, +) -> Result, ApiError> { + // Check permission to edit series metadata + auth.require_permission(&Permission::SeriesWrite)?; + + if request.series_ids.is_empty() { + return Err(ApiError::BadRequest( + "At least one series ID is required".to_string(), + )); + } + + // Limit bulk request size to prevent worker queue DoS + if request.series_ids.len() > MAX_BULK_SERIES_COUNT { + return Err(ApiError::BadRequest(format!( + "Too many series in bulk request. Maximum is {}, got {}. \ + Please split into smaller batches.", + MAX_BULK_SERIES_COUNT, + request.series_ids.len() + ))); + } + + // Verify plugin exists and is enabled + let plugin = PluginsRepository::get_by_id(&state.db, request.plugin_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get plugin: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Plugin not found".to_string()))?; + + if !plugin.enabled { + return Err(ApiError::BadRequest("Plugin is disabled".to_string())); + } + + // Create tasks for each series + let mut task_ids = Vec::new(); + let mut enqueued = 0; + let mut skipped_not_found = 0; + let mut skipped_library_mismatch = 0; + + for series_id in &request.series_ids { + // Verify series exists (skip if not) + let series = match SeriesRepository::get_by_id(&state.db, *series_id).await { + Ok(Some(s)) => s, + Ok(None) => { + skipped_not_found += 1; + continue; + } + Err(e) => { + tracing::warn!("Failed to get series {}: {}", series_id, e); + skipped_not_found += 1; + continue; + } + }; + + // Check if plugin applies to this series' library + if !plugin.applies_to_library(series.library_id) { + skipped_library_mismatch += 1; + continue; + } + + let task_type = TaskType::PluginAutoMatch { + series_id: *series_id, + plugin_id: request.plugin_id, + source_scope: Some("series:bulk".to_string()), + }; + + match TaskRepository::enqueue(&state.db, task_type, 0, None).await { + Ok(task_id) => { + task_ids.push(task_id); + enqueued += 1; + } + Err(e) => { + tracing::warn!( + "Failed to enqueue auto-match task for series {}: {}", + series_id, + e + ); + } + } + } + + let message = if enqueued == request.series_ids.len() { + format!("Enqueued {} auto-match task(s)", enqueued) + } else { + let mut parts = vec![format!( + "Enqueued {} of {} task(s)", + enqueued, + request.series_ids.len() + )]; + if skipped_library_mismatch > 0 { + parts.push(format!( + "{} skipped (plugin doesn't apply to library)", + skipped_library_mismatch + )); + } + if skipped_not_found > 0 { + parts.push(format!("{} skipped (series not found)", skipped_not_found)); + } + parts.join(", ") + }; + + Ok(Json(EnqueueAutoMatchResponse { + success: enqueued > 0, + tasks_enqueued: enqueued, + task_ids, + message, + })) +} + +/// Enqueue plugin auto-match tasks for all series in a library +/// +/// Creates background tasks to auto-match metadata for all series in a library using +/// the specified plugin. Each series gets its own task that runs asynchronously. +#[utoipa::path( + post, + path = "/api/v1/libraries/{id}/metadata/auto-match/task", + params( + ("id" = Uuid, Path, description = "Library ID") + ), + request_body = EnqueueLibraryAutoMatchRequest, + responses( + (status = 200, description = "Tasks enqueued", body = EnqueueAutoMatchResponse), + (status = 400, description = "Invalid request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "No permission to edit series"), + (status = 404, description = "Library or plugin not found"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Plugin Actions" +)] +pub async fn enqueue_library_auto_match_tasks( + State(state): State>, + auth: AuthContext, + Path(library_id): Path, + Json(request): Json, +) -> Result, ApiError> { + // Check permission to edit series metadata + auth.require_permission(&Permission::SeriesWrite)?; + + // Verify library exists + let _library = LibraryRepository::get_by_id(&state.db, library_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get library: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Library not found".to_string()))?; + + // Verify plugin exists and is enabled + let plugin = PluginsRepository::get_by_id(&state.db, request.plugin_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get plugin: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Plugin not found".to_string()))?; + + if !plugin.enabled { + return Err(ApiError::BadRequest("Plugin is disabled".to_string())); + } + + // Check if plugin applies to this library + if !plugin.applies_to_library(library_id) { + return Err(ApiError::BadRequest(format!( + "Plugin '{}' is not configured to apply to this library", + plugin.display_name + ))); + } + + // Get all series in the library + let series_list = SeriesRepository::list_by_library(&state.db, library_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get series: {}", e)))?; + + if series_list.is_empty() { + return Ok(Json(EnqueueAutoMatchResponse { + success: true, + tasks_enqueued: 0, + task_ids: vec![], + message: "No series in library".to_string(), + })); + } + + // Limit library auto-match to prevent worker queue DoS + if series_list.len() > MAX_LIBRARY_SERIES_COUNT { + return Err(ApiError::BadRequest(format!( + "Library has too many series ({}) for auto-match. Maximum is {}. \ + Please use the bulk endpoint to process in batches.", + series_list.len(), + MAX_LIBRARY_SERIES_COUNT + ))); + } + + // Create tasks for each series + let mut task_ids = Vec::new(); + let mut enqueued = 0; + + for series in &series_list { + let task_type = TaskType::PluginAutoMatch { + series_id: series.id, + plugin_id: request.plugin_id, + source_scope: Some("library:detail".to_string()), + }; + + match TaskRepository::enqueue(&state.db, task_type, 0, None).await { + Ok(task_id) => { + task_ids.push(task_id); + enqueued += 1; + } + Err(e) => { + tracing::warn!( + "Failed to enqueue auto-match task for series {}: {}", + series.id, + e + ); + } + } + } + + let message = if enqueued == series_list.len() { + format!("Enqueued {} auto-match task(s) for library", enqueued) + } else { + format!( + "Enqueued {} of {} auto-match task(s) for library", + enqueued, + series_list.len() + ) + }; + + Ok(Json(EnqueueAutoMatchResponse { + success: enqueued > 0, + tasks_enqueued: enqueued, + task_ids, + message, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_field_preview_will_apply() { + let mut will_apply = 0; + let mut locked = 0; + let mut no_permission = 0; + let mut unchanged = 0; + let mut not_provided = 0; + + let preview = build_field_preview( + "title", + Some(serde_json::json!("Old Title")), + Some(serde_json::json!("New Title")), + false, + true, + &mut will_apply, + &mut locked, + &mut no_permission, + &mut unchanged, + &mut not_provided, + ); + + assert_eq!(preview.status, FieldApplyStatus::WillApply); + assert!(preview.reason.is_none()); + assert_eq!(will_apply, 1); + } + + #[test] + fn test_build_field_preview_locked() { + let mut will_apply = 0; + let mut locked = 0; + let mut no_permission = 0; + let mut unchanged = 0; + let mut not_provided = 0; + + let preview = build_field_preview( + "title", + Some(serde_json::json!("Old Title")), + Some(serde_json::json!("New Title")), + true, // locked + true, + &mut will_apply, + &mut locked, + &mut no_permission, + &mut unchanged, + &mut not_provided, + ); + + assert_eq!(preview.status, FieldApplyStatus::Locked); + assert_eq!(locked, 1); + } + + #[test] + fn test_build_field_preview_no_permission() { + let mut will_apply = 0; + let mut locked = 0; + let mut no_permission = 0; + let mut unchanged = 0; + let mut not_provided = 0; + + let preview = build_field_preview( + "title", + Some(serde_json::json!("Old Title")), + Some(serde_json::json!("New Title")), + false, + false, // no permission + &mut will_apply, + &mut locked, + &mut no_permission, + &mut unchanged, + &mut not_provided, + ); + + assert_eq!(preview.status, FieldApplyStatus::NoPermission); + assert_eq!(no_permission, 1); + } + + #[test] + fn test_build_field_preview_unchanged() { + let mut will_apply = 0; + let mut locked = 0; + let mut no_permission = 0; + let mut unchanged = 0; + let mut not_provided = 0; + + let preview = build_field_preview( + "title", + Some(serde_json::json!("Same Title")), + Some(serde_json::json!("Same Title")), + false, + true, + &mut will_apply, + &mut locked, + &mut no_permission, + &mut unchanged, + &mut not_provided, + ); + + assert_eq!(preview.status, FieldApplyStatus::Unchanged); + assert_eq!(unchanged, 1); + } + + #[test] + fn test_build_field_preview_not_provided() { + let mut will_apply = 0; + let mut locked = 0; + let mut no_permission = 0; + let mut unchanged = 0; + let mut not_provided = 0; + + let preview = build_field_preview( + "title", + Some(serde_json::json!("Old Title")), + None, // not provided + false, + true, + &mut will_apply, + &mut locked, + &mut no_permission, + &mut unchanged, + &mut not_provided, + ); + + assert_eq!(preview.status, FieldApplyStatus::NotProvided); + assert_eq!(not_provided, 1); + } +} diff --git a/src/api/routes/v1/handlers/plugins.rs b/src/api/routes/v1/handlers/plugins.rs new file mode 100644 index 00000000..d0ec9eac --- /dev/null +++ b/src/api/routes/v1/handlers/plugins.rs @@ -0,0 +1,983 @@ +//! Plugins API handlers +//! +//! Provides CRUD operations for plugins that communicate with Codex via JSON-RPC over stdio. +//! Requires the `PluginsManage` permission (granted to Admins by default). + +use super::super::dto::{ + available_credential_delivery_methods, available_permissions, available_scopes, + parse_permission, parse_scope, CreatePluginRequest, EnvVarDto, PluginDto, PluginFailureDto, + PluginFailuresResponse, PluginHealthDto, PluginHealthResponse, PluginManifestDto, + PluginStatusResponse, PluginTestResult, PluginsListResponse, UpdatePluginRequest, +}; +use crate::api::{error::ApiError, extractors::AuthContext, permissions::Permission, AppState}; +use crate::db::entities::plugins::PluginPermission; +use crate::db::repositories::{PluginFailuresRepository, PluginsRepository}; +use crate::services::plugin::process::{allowed_commands_description, is_command_allowed}; +use crate::services::plugin::protocol::PluginScope; +use crate::services::PluginHealthStatus; +use axum::{ + extract::{Path, State}, + http::StatusCode, + Json, +}; +use std::sync::Arc; +use std::time::Instant; +use utoipa::OpenApi; +use uuid::Uuid; + +#[derive(OpenApi)] +#[openapi( + paths( + list_plugins, + create_plugin, + get_plugin, + update_plugin, + delete_plugin, + enable_plugin, + disable_plugin, + test_plugin, + get_plugin_health, + reset_plugin_failures, + get_plugin_failures, + ), + components(schemas( + PluginDto, + PluginsListResponse, + CreatePluginRequest, + UpdatePluginRequest, + PluginTestResult, + PluginStatusResponse, + PluginHealthDto, + PluginHealthResponse, + PluginManifestDto, + PluginFailureDto, + PluginFailuresResponse, + EnvVarDto, + )), + tags( + (name = "Plugins", description = "Admin-managed external plugin processes") + ) +)] +#[allow(dead_code)] // OpenAPI documentation struct - referenced by utoipa derive macros +pub struct PluginsApi; + +/// List all plugins +#[utoipa::path( + get, + path = "/api/v1/admin/plugins", + responses( + (status = 200, description = "Plugins retrieved", body = PluginsListResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "PluginsManage permission required"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Plugins" +)] +pub async fn list_plugins( + State(state): State>, + auth: AuthContext, +) -> Result, ApiError> { + auth.require_permission(&Permission::PluginsManage)?; + + let plugins = PluginsRepository::get_all(&state.db) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get plugins: {}", e)))?; + + let total = plugins.len(); + let dtos: Vec = plugins.into_iter().map(Into::into).collect(); + + Ok(Json(PluginsListResponse { + plugins: dtos, + total, + })) +} + +/// Create a new plugin +/// +/// Creates a new plugin configuration. If the plugin is created with `enabled: true`, +/// an automatic health check is performed to verify connectivity. +#[utoipa::path( + post, + path = "/api/v1/admin/plugins", + request_body = CreatePluginRequest, + responses( + (status = 201, description = "Plugin created", body = PluginStatusResponse), + (status = 400, description = "Invalid request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "PluginsManage permission required"), + (status = 409, description = "Plugin with this name already exists"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Plugins" +)] +pub async fn create_plugin( + State(state): State>, + auth: AuthContext, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + auth.require_permission(&Permission::PluginsManage)?; + + // Validate name + if !is_valid_plugin_name(&request.name) { + return Err(ApiError::BadRequest( + "Invalid plugin name. Use lowercase alphanumeric characters and hyphens only. Cannot start or end with a hyphen." + .to_string(), + )); + } + + // Validate plugin type + let valid_types = ["system", "user"]; + if !valid_types.contains(&request.plugin_type.as_str()) { + return Err(ApiError::BadRequest(format!( + "Invalid plugin type '{}'. Valid types: {:?}", + request.plugin_type, valid_types + ))); + } + + // Validate command against allowlist (security) + if !is_command_allowed(&request.command) { + return Err(ApiError::BadRequest(format!( + "Command '{}' is not in the plugin allowlist. Allowed commands: {}. \ + To add custom commands, set the CODEX_PLUGIN_ALLOWED_COMMANDS environment variable.", + request.command, + allowed_commands_description() + ))); + } + + // Validate credential delivery + let valid_delivery = available_credential_delivery_methods(); + if !valid_delivery.contains(&request.credential_delivery.as_str()) { + return Err(ApiError::BadRequest(format!( + "Invalid credential delivery '{}'. Valid options: {:?}", + request.credential_delivery, valid_delivery + ))); + } + + // Validate permissions + let valid_perms = available_permissions(); + let mut permissions: Vec = Vec::new(); + for perm_str in &request.permissions { + if !valid_perms.contains(&perm_str.as_str()) { + return Err(ApiError::BadRequest(format!( + "Invalid permission '{}'. Valid permissions: {:?}", + perm_str, valid_perms + ))); + } + if let Some(perm) = parse_permission(perm_str) { + permissions.push(perm); + } + } + + // Validate scopes + let valid_scopes = available_scopes(); + let mut scopes: Vec = Vec::new(); + for scope_str in &request.scopes { + if !valid_scopes.contains(&scope_str.as_str()) { + return Err(ApiError::BadRequest(format!( + "Invalid scope '{}'. Valid scopes: {:?}", + scope_str, valid_scopes + ))); + } + if let Some(scope) = parse_scope(scope_str) { + scopes.push(scope); + } + } + + // Check if name already exists + if PluginsRepository::get_by_name(&state.db, &request.name) + .await + .map_err(|e| ApiError::Internal(format!("Failed to check existing: {}", e)))? + .is_some() + { + return Err(ApiError::Conflict(format!( + "Plugin with name '{}' already exists", + request.name + ))); + } + + // Convert env vars + let is_enabled = request.enabled; + let env: Vec<(String, String)> = request.env.into_iter().map(|e| (e.key, e.value)).collect(); + + let plugin = PluginsRepository::create( + &state.db, + &request.name, + &request.display_name, + request.description.as_deref(), + &request.plugin_type, + &request.command, + request.args, + env, + request.working_directory.as_deref(), + permissions, + scopes, + request.library_ids, // Library filtering: empty = all libraries + request.credentials.as_ref(), + &request.credential_delivery, + request.config, + is_enabled, + Some(auth.user_id), + request.rate_limit_requests_per_minute, + ) + .await + .map_err(|e| ApiError::Internal(format!("Failed to create plugin: {}", e)))?; + + let plugin_id = plugin.id; + + // Reload the plugin manager to pick up the new plugin + if let Err(e) = state.plugin_manager.reload(plugin_id).await { + tracing::warn!("Failed to reload plugin manager after create: {}", e); + } + + // Perform automatic health check if plugin is enabled + if is_enabled { + let start = Instant::now(); + let health_result = state.plugin_manager.ping(plugin_id).await; + let latency = start.elapsed().as_millis() as u64; + + // Re-fetch the plugin to get updated health status after ping + let updated_plugin = PluginsRepository::get_by_id(&state.db, plugin_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get updated plugin: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Plugin not found".to_string()))?; + + let (health_check_passed, health_check_error, message) = match health_result { + Ok(()) => ( + Some(true), + None, + "Plugin created and health check passed".to_string(), + ), + Err(e) => ( + Some(false), + Some(e.to_string()), + format!("Plugin created but health check failed: {}", e), + ), + }; + + return Ok(( + StatusCode::CREATED, + Json(PluginStatusResponse { + plugin: updated_plugin.into(), + message, + health_check_performed: true, + health_check_passed, + health_check_latency_ms: Some(latency), + health_check_error, + }), + )); + } + + // Plugin created disabled - no health check + Ok(( + StatusCode::CREATED, + Json(PluginStatusResponse { + plugin: plugin.into(), + message: "Plugin created (disabled)".to_string(), + health_check_performed: false, + health_check_passed: None, + health_check_latency_ms: None, + health_check_error: None, + }), + )) +} + +/// Get a plugin by ID +#[utoipa::path( + get, + path = "/api/v1/admin/plugins/{id}", + params( + ("id" = Uuid, Path, description = "Plugin ID") + ), + responses( + (status = 200, description = "Plugin retrieved", body = PluginDto), + (status = 401, description = "Unauthorized"), + (status = 403, description = "PluginsManage permission required"), + (status = 404, description = "Plugin not found"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Plugins" +)] +pub async fn get_plugin( + State(state): State>, + auth: AuthContext, + Path(id): Path, +) -> Result, ApiError> { + auth.require_permission(&Permission::PluginsManage)?; + + let plugin = PluginsRepository::get_by_id(&state.db, id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get plugin: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Plugin not found".to_string()))?; + + Ok(Json(plugin.into())) +} + +/// Update a plugin +#[utoipa::path( + patch, + path = "/api/v1/admin/plugins/{id}", + params( + ("id" = Uuid, Path, description = "Plugin ID") + ), + request_body = UpdatePluginRequest, + responses( + (status = 200, description = "Plugin updated", body = PluginDto), + (status = 400, description = "Invalid request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "PluginsManage permission required"), + (status = 404, description = "Plugin not found"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Plugins" +)] +pub async fn update_plugin( + State(state): State>, + auth: AuthContext, + Path(id): Path, + Json(request): Json, +) -> Result, ApiError> { + auth.require_permission(&Permission::PluginsManage)?; + + // Validate command against allowlist if provided (security) + if let Some(ref command) = request.command { + if !is_command_allowed(command) { + return Err(ApiError::BadRequest(format!( + "Command '{}' is not in the plugin allowlist. Allowed commands: {}. \ + To add custom commands, set the CODEX_PLUGIN_ALLOWED_COMMANDS environment variable.", + command, + allowed_commands_description() + ))); + } + } + + // Validate credential delivery if provided + if let Some(ref delivery) = request.credential_delivery { + let valid_delivery = available_credential_delivery_methods(); + if !valid_delivery.contains(&delivery.as_str()) { + return Err(ApiError::BadRequest(format!( + "Invalid credential delivery '{}'. Valid options: {:?}", + delivery, valid_delivery + ))); + } + } + + // Validate permissions if provided + let permissions: Option> = + if let Some(ref perm_strs) = request.permissions { + let valid_perms = available_permissions(); + let mut perms = Vec::new(); + for perm_str in perm_strs { + if !valid_perms.contains(&perm_str.as_str()) { + return Err(ApiError::BadRequest(format!( + "Invalid permission '{}'. Valid permissions: {:?}", + perm_str, valid_perms + ))); + } + if let Some(perm) = parse_permission(perm_str) { + perms.push(perm); + } + } + Some(perms) + } else { + None + }; + + // Validate scopes if provided + let scopes: Option> = if let Some(ref scope_strs) = request.scopes { + let valid_scopes = available_scopes(); + let mut parsed_scopes = Vec::new(); + for scope_str in scope_strs { + if !valid_scopes.contains(&scope_str.as_str()) { + return Err(ApiError::BadRequest(format!( + "Invalid scope '{}'. Valid scopes: {:?}", + scope_str, valid_scopes + ))); + } + if let Some(scope) = parse_scope(scope_str) { + parsed_scopes.push(scope); + } + } + Some(parsed_scopes) + } else { + None + }; + + // Convert env vars if provided + let env: Option> = request + .env + .map(|e| e.into_iter().map(|ev| (ev.key, ev.value)).collect()); + + // Update the plugin + let plugin = PluginsRepository::update( + &state.db, + id, + request.display_name, + request.description, + request.command, + request.args, + env, + request.working_directory, + permissions, + scopes, + request.library_ids, // Library filtering: None = no change, Some([]) = all, Some([ids]) = specific + request.credential_delivery, + request.config, + Some(auth.user_id), + request.rate_limit_requests_per_minute, + ) + .await + .map_err(|e| { + if e.to_string().contains("not found") { + ApiError::NotFound("Plugin not found".to_string()) + } else { + ApiError::Internal(format!("Failed to update plugin: {}", e)) + } + })?; + + // Update credentials separately if provided + if request.credentials.is_some() { + PluginsRepository::update_credentials( + &state.db, + id, + request.credentials.as_ref(), + Some(auth.user_id), + ) + .await + .map_err(|e| ApiError::Internal(format!("Failed to update credentials: {}", e)))?; + + // Reload the plugin manager to pick up the updated plugin + if let Err(e) = state.plugin_manager.reload(id).await { + tracing::warn!("Failed to reload plugin manager after update: {}", e); + } + + // Re-fetch to get updated plugin + let updated = PluginsRepository::get_by_id(&state.db, id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get updated plugin: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Plugin not found".to_string()))?; + return Ok(Json(updated.into())); + } + + // Reload the plugin manager to pick up the updated plugin + if let Err(e) = state.plugin_manager.reload(id).await { + tracing::warn!("Failed to reload plugin manager after update: {}", e); + } + + Ok(Json(plugin.into())) +} + +/// Delete a plugin +#[utoipa::path( + delete, + path = "/api/v1/admin/plugins/{id}", + params( + ("id" = Uuid, Path, description = "Plugin ID") + ), + responses( + (status = 204, description = "Plugin deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "PluginsManage permission required"), + (status = 404, description = "Plugin not found"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Plugins" +)] +pub async fn delete_plugin( + State(state): State>, + auth: AuthContext, + Path(id): Path, +) -> Result { + auth.require_permission(&Permission::PluginsManage)?; + + // Stop the plugin process if running (via PluginManager) + if let Err(e) = state.plugin_manager.stop_plugin(id).await { + tracing::warn!("Failed to stop plugin before delete: {}", e); + } + + let deleted = PluginsRepository::delete(&state.db, id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to delete plugin: {}", e)))?; + + if deleted { + // Remove the plugin from the manager's memory + state.plugin_manager.remove(id).await; + // Remove the plugin's metrics + state.plugin_metrics_service.remove_plugin(id).await; + Ok(StatusCode::NO_CONTENT) + } else { + Err(ApiError::NotFound("Plugin not found".to_string())) + } +} + +/// Enable a plugin +/// +/// Enables the plugin and automatically performs a health check to verify connectivity. +#[utoipa::path( + post, + path = "/api/v1/admin/plugins/{id}/enable", + params( + ("id" = Uuid, Path, description = "Plugin ID") + ), + responses( + (status = 200, description = "Plugin enabled", body = PluginStatusResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "PluginsManage permission required"), + (status = 404, description = "Plugin not found"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Plugins" +)] +pub async fn enable_plugin( + State(state): State>, + auth: AuthContext, + Path(id): Path, +) -> Result, ApiError> { + auth.require_permission(&Permission::PluginsManage)?; + + let _plugin = PluginsRepository::enable(&state.db, id, Some(auth.user_id)) + .await + .map_err(|e| { + if e.to_string().contains("not found") { + ApiError::NotFound("Plugin not found".to_string()) + } else { + ApiError::Internal(format!("Failed to enable plugin: {}", e)) + } + })?; + + // Reload the plugin manager to pick up the enabled plugin + if let Err(e) = state.plugin_manager.reload(id).await { + tracing::warn!("Failed to reload plugin manager after enable: {}", e); + } + + // Perform automatic health check + let start = Instant::now(); + let health_result = state.plugin_manager.ping(id).await; + let latency = start.elapsed().as_millis() as u64; + + // Re-fetch the plugin to get updated health status after ping + let updated_plugin = PluginsRepository::get_by_id(&state.db, id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get updated plugin: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Plugin not found".to_string()))?; + + let (health_check_passed, health_check_error, message) = match health_result { + Ok(()) => ( + Some(true), + None, + "Plugin enabled and health check passed".to_string(), + ), + Err(e) => ( + Some(false), + Some(e.to_string()), + format!("Plugin enabled but health check failed: {}", e), + ), + }; + + Ok(Json(PluginStatusResponse { + plugin: updated_plugin.into(), + message, + health_check_performed: true, + health_check_passed, + health_check_latency_ms: Some(latency), + health_check_error, + })) +} + +/// Disable a plugin +#[utoipa::path( + post, + path = "/api/v1/admin/plugins/{id}/disable", + params( + ("id" = Uuid, Path, description = "Plugin ID") + ), + responses( + (status = 200, description = "Plugin disabled", body = PluginStatusResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "PluginsManage permission required"), + (status = 404, description = "Plugin not found"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Plugins" +)] +pub async fn disable_plugin( + State(state): State>, + auth: AuthContext, + Path(id): Path, +) -> Result, ApiError> { + auth.require_permission(&Permission::PluginsManage)?; + + // Stop the plugin process if running (via PluginManager) + if let Err(e) = state.plugin_manager.stop_plugin(id).await { + tracing::warn!("Failed to stop plugin before disable: {}", e); + } + + let plugin = PluginsRepository::disable(&state.db, id, Some(auth.user_id)) + .await + .map_err(|e| { + if e.to_string().contains("not found") { + ApiError::NotFound("Plugin not found".to_string()) + } else { + ApiError::Internal(format!("Failed to disable plugin: {}", e)) + } + })?; + + // Reload the plugin manager to remove the disabled plugin from memory + if let Err(e) = state.plugin_manager.reload(id).await { + tracing::warn!("Failed to reload plugin manager after disable: {}", e); + } + + // Mark plugin as unhealthy in metrics + state + .plugin_metrics_service + .set_health_status(id, PluginHealthStatus::Unhealthy) + .await; + + Ok(Json(PluginStatusResponse { + plugin: plugin.into(), + message: "Plugin disabled successfully".to_string(), + health_check_performed: false, + health_check_passed: None, + health_check_latency_ms: None, + health_check_error: None, + })) +} + +/// Test a plugin connection +/// +/// Spawns the plugin process, sends an initialize request, and returns the manifest. +#[utoipa::path( + post, + path = "/api/v1/admin/plugins/{id}/test", + params( + ("id" = Uuid, Path, description = "Plugin ID") + ), + responses( + (status = 200, description = "Test completed", body = PluginTestResult), + (status = 401, description = "Unauthorized"), + (status = 403, description = "PluginsManage permission required"), + (status = 404, description = "Plugin not found"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Plugins" +)] +pub async fn test_plugin( + State(state): State>, + auth: AuthContext, + Path(id): Path, +) -> Result, ApiError> { + auth.require_permission(&Permission::PluginsManage)?; + + let plugin = PluginsRepository::get_by_id(&state.db, id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get plugin: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Plugin not found".to_string()))?; + + // Get the plugin manager and test the connection + let start = Instant::now(); + + // Try to spawn and initialize the plugin + match state.plugin_manager.test_plugin(&state.db, &plugin).await { + Ok(manifest) => { + let latency = start.elapsed().as_millis() as u64; + + // Update the cached manifest + if let Err(e) = PluginsRepository::update_manifest( + &state.db, + id, + Some(serde_json::to_value(&manifest).unwrap_or_default()), + ) + .await + { + tracing::warn!("Failed to cache plugin manifest: {}", e); + } + + // Record success + if let Err(e) = PluginsRepository::record_success(&state.db, id).await { + tracing::warn!("Failed to record plugin success: {}", e); + } + + Ok(Json(PluginTestResult { + success: true, + message: format!( + "Successfully connected to {} v{}", + manifest.display_name, manifest.version + ), + latency_ms: Some(latency), + manifest: Some(PluginManifestDto::from(manifest)), + })) + } + Err(e) => { + let latency = start.elapsed().as_millis() as u64; + + // Record failure + if let Err(record_err) = + PluginsRepository::record_failure(&state.db, id, Some(&e.to_string())).await + { + tracing::warn!("Failed to record plugin failure: {}", record_err); + } + + Ok(Json(PluginTestResult { + success: false, + message: format!("Failed to connect: {}", e), + latency_ms: Some(latency), + manifest: None, + })) + } + } +} + +/// Get plugin health information +#[utoipa::path( + get, + path = "/api/v1/admin/plugins/{id}/health", + params( + ("id" = Uuid, Path, description = "Plugin ID") + ), + responses( + (status = 200, description = "Health information retrieved", body = PluginHealthResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "PluginsManage permission required"), + (status = 404, description = "Plugin not found"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Plugins" +)] +pub async fn get_plugin_health( + State(state): State>, + auth: AuthContext, + Path(id): Path, +) -> Result, ApiError> { + auth.require_permission(&Permission::PluginsManage)?; + + let plugin = PluginsRepository::get_by_id(&state.db, id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get plugin: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Plugin not found".to_string()))?; + + Ok(Json(PluginHealthResponse { + health: plugin.into(), + })) +} + +/// Reset plugin failure count +/// +/// Clears the failure count and disabled reason, allowing the plugin to be used again. +#[utoipa::path( + post, + path = "/api/v1/admin/plugins/{id}/reset", + params( + ("id" = Uuid, Path, description = "Plugin ID") + ), + responses( + (status = 200, description = "Failure count reset", body = PluginStatusResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "PluginsManage permission required"), + (status = 404, description = "Plugin not found"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Plugins" +)] +pub async fn reset_plugin_failures( + State(state): State>, + auth: AuthContext, + Path(id): Path, +) -> Result, ApiError> { + auth.require_permission(&Permission::PluginsManage)?; + + let plugin = PluginsRepository::reset_failure_count(&state.db, id, Some(auth.user_id)) + .await + .map_err(|e| { + if e.to_string().contains("not found") { + ApiError::NotFound("Plugin not found".to_string()) + } else { + ApiError::Internal(format!("Failed to reset failure count: {}", e)) + } + })?; + + Ok(Json(PluginStatusResponse { + plugin: plugin.into(), + message: "Failure count reset successfully".to_string(), + health_check_performed: false, + health_check_passed: None, + health_check_latency_ms: None, + health_check_error: None, + })) +} + +/// Query parameters for plugin failures endpoint +#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)] +pub struct PluginFailuresQuery { + /// Maximum number of failures to return + #[serde(default = "default_failures_limit")] + pub limit: u64, + + /// Number of failures to skip + #[serde(default)] + pub offset: u64, +} + +fn default_failures_limit() -> u64 { + 20 +} + +/// Get plugin failure history +/// +/// Returns failure events for a plugin, including time-window statistics. +#[utoipa::path( + get, + path = "/api/v1/admin/plugins/{id}/failures", + params( + ("id" = Uuid, Path, description = "Plugin ID"), + PluginFailuresQuery, + ), + responses( + (status = 200, description = "Failures retrieved", body = PluginFailuresResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "PluginsManage permission required"), + (status = 404, description = "Plugin not found"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Plugins" +)] +pub async fn get_plugin_failures( + State(state): State>, + auth: AuthContext, + Path(id): Path, + axum::extract::Query(query): axum::extract::Query, +) -> Result, ApiError> { + auth.require_permission(&Permission::PluginsManage)?; + + // Verify plugin exists + PluginsRepository::get_by_id(&state.db, id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get plugin: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Plugin not found".to_string()))?; + + // Get paginated failures + let (failures, total) = + PluginFailuresRepository::get_failures_paginated(&state.db, id, query.limit, query.offset) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get failures: {}", e)))?; + + // Get count within time window + // Use the default settings (can be made configurable via settings later) + let window_seconds = 3600_i64; // 1 hour + let threshold = 3_u32; + + let window_failures = + PluginFailuresRepository::count_failures_in_window(&state.db, id, window_seconds) + .await + .map_err(|e| ApiError::Internal(format!("Failed to count failures: {}", e)))?; + + let failure_dtos: Vec = failures.into_iter().map(Into::into).collect(); + + Ok(Json(PluginFailuresResponse { + failures: failure_dtos, + total, + window_failures, + window_seconds, + threshold, + })) +} + +/// Validate a plugin name (lowercase alphanumeric with hyphens) +/// +/// A valid plugin name: +/// - Is 1-100 characters long +/// - Contains only lowercase letters, digits, or hyphens +/// - Does not start or end with a hyphen +fn is_valid_plugin_name(name: &str) -> bool { + if name.is_empty() || name.len() > 100 { + return false; + } + + name.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + && !name.starts_with('-') + && !name.ends_with('-') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_plugin_names() { + // Basic names + assert!(is_valid_plugin_name("mangabaka")); + assert!(is_valid_plugin_name("provider123")); + + // With hyphens + assert!(is_valid_plugin_name("my-plugin")); + assert!(is_valid_plugin_name("anilist-api")); + assert!(is_valid_plugin_name("my-custom-plugin")); + + // Numbers and hyphens + assert!(is_valid_plugin_name("plugin-v2")); + assert!(is_valid_plugin_name("api123-test")); + } + + #[test] + fn test_invalid_plugin_names() { + // Empty + assert!(!is_valid_plugin_name("")); + + // Uppercase + assert!(!is_valid_plugin_name("MangaBaka")); + assert!(!is_valid_plugin_name("My-Plugin")); + + // Spaces + assert!(!is_valid_plugin_name("my plugin")); + + // Underscores (not allowed) + assert!(!is_valid_plugin_name("my_plugin")); + assert!(!is_valid_plugin_name("anilist_api")); + + // Starts with hyphen + assert!(!is_valid_plugin_name("-plugin")); + + // Ends with hyphen + assert!(!is_valid_plugin_name("plugin-")); + + // Too long + let long_name = "a".repeat(101); + assert!(!is_valid_plugin_name(&long_name)); + + // Special characters + assert!(!is_valid_plugin_name("plugin@name")); + assert!(!is_valid_plugin_name("plugin.name")); + } +} diff --git a/src/api/routes/v1/handlers/series.rs b/src/api/routes/v1/handlers/series.rs index 3060b3e5..1ddfdc21 100644 --- a/src/api/routes/v1/handlers/series.rs +++ b/src/api/routes/v1/handlers/series.rs @@ -1394,6 +1394,9 @@ pub async fn set_series_cover_source( ))); } + // Regenerate the series thumbnail to reflect the new cover + regenerate_series_thumbnail(&state, series_id).await; + // Emit cover updated event let event = EntityChangeEvent { event: EntityEvent::CoverUpdated { @@ -1532,11 +1535,27 @@ pub async fn get_series_thumbnail( ); } + // Generate ETag from cover ID + thumbnail size + current timestamp + // This ensures browser cache is busted when cover changes + let now = std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let etag = format!( + "\"{:x}-{:x}-{:x}\"", + cover.id.as_u128(), + thumbnail_data.len(), + now + ); + let last_modified_str = fmt_http_date(std::time::SystemTime::now()); + return Ok(Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "image/jpeg") .header(header::CACHE_CONTROL, "public, max-age=31536000") .header(header::CONTENT_LENGTH, thumbnail_data.len()) + .header(header::ETAG, &etag) + .header(header::LAST_MODIFIED, last_modified_str) .body(Body::from(thumbnail_data)) .unwrap()); } @@ -4741,6 +4760,9 @@ pub async fn select_series_cover( .await .map_err(|e| ApiError::Internal(format!("Failed to update series timestamp: {}", e)))?; + // Regenerate the series thumbnail to reflect the new cover + regenerate_series_thumbnail(&state, series_id).await; + // Emit cover updated event let event = EntityChangeEvent { event: EntityEvent::CoverUpdated { @@ -4807,6 +4829,9 @@ pub async fn reset_series_cover( .await .map_err(|e| ApiError::Internal(format!("Failed to update series timestamp: {}", e)))?; + // Regenerate the series thumbnail to use the default cover (first book's cover) + regenerate_series_thumbnail(&state, series_id).await; + // Emit cover updated event let event = EntityChangeEvent { event: EntityEvent::CoverUpdated { @@ -4902,6 +4927,11 @@ pub async fn delete_series_cover( .await .map_err(|e| ApiError::Internal(format!("Failed to update series timestamp: {}", e)))?; + // Regenerate the series thumbnail (will use alternate cover or default) + if cover.is_selected { + regenerate_series_thumbnail(&state, series_id).await; + } + // Emit cover updated event let event = EntityChangeEvent { event: EntityEvent::CoverUpdated { @@ -5041,6 +5071,42 @@ pub async fn get_series_cover_image( .unwrap()) } +/// Regenerate the series thumbnail by deleting the cache and queuing a new generation task. +/// +/// This should be called whenever a series cover is selected/unselected to ensure +/// the cached thumbnail reflects the current cover selection. +async fn regenerate_series_thumbnail(state: &AuthState, series_id: Uuid) { + use crate::db::repositories::TaskRepository; + use crate::tasks::types::TaskType; + + // Delete the cached series thumbnail first + if let Err(e) = state + .thumbnail_service + .delete_series_thumbnail(series_id) + .await + { + tracing::warn!( + "Failed to delete series thumbnail cache for {}: {}", + series_id, + e + ); + } + + // Queue a task to regenerate the thumbnail with force=true + let task_type = TaskType::GenerateSeriesThumbnail { + series_id, + force: true, // Force regeneration since we just deleted the cache + }; + + if let Err(e) = TaskRepository::enqueue(&state.db, task_type, 0, None).await { + tracing::warn!( + "Failed to queue series thumbnail regeneration task for {}: {}", + series_id, + e + ); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/api/routes/v1/handlers/task_queue.rs b/src/api/routes/v1/handlers/task_queue.rs index 14167536..56a6e4c1 100644 --- a/src/api/routes/v1/handlers/task_queue.rs +++ b/src/api/routes/v1/handlers/task_queue.rs @@ -525,20 +525,34 @@ pub async fn nuke_all_tasks( // Thumbnail generation endpoints +/// Request body for batch book thumbnail generation #[derive(Debug, Deserialize, ToSchema)] -pub struct GenerateThumbnailsRequest { - /// Library ID to generate thumbnails for (optional) +pub struct GenerateBookThumbnailsRequest { + /// If true, regenerate all thumbnails even if they exist. If false (default), only generate missing thumbnails. + #[serde(default)] + #[schema(example = false)] + pub force: bool, + + /// Optional: scope to a specific library #[schema(example = "550e8400-e29b-41d4-a716-446655440000")] pub library_id: Option, - /// Series ID to generate thumbnails for (optional, takes precedence over library_id) + /// Optional: scope to a specific series (within library if both provided) #[schema(example = "550e8400-e29b-41d4-a716-446655440001")] pub series_id: Option, +} +/// Request body for batch series thumbnail generation +#[derive(Debug, Deserialize, ToSchema)] +pub struct GenerateSeriesThumbnailsRequest { /// If true, regenerate all thumbnails even if they exist. If false (default), only generate missing thumbnails. #[serde(default)] #[schema(example = false)] pub force: bool, + + /// Optional: scope to a specific library + #[schema(example = "550e8400-e29b-41d4-a716-446655440000")] + pub library_id: Option, } /// Generate thumbnails for books in a scope @@ -558,8 +572,8 @@ pub struct GenerateThumbnailsRequest { /// - `tasks:write` #[utoipa::path( post, - path = "/api/v1/thumbnails/generate", - request_body = GenerateThumbnailsRequest, + path = "/api/v1/books/thumbnails/generate", + request_body = GenerateBookThumbnailsRequest, responses( (status = 200, description = "Thumbnail generation task queued", body = CreateTaskResponse), (status = 403, description = "Permission denied"), @@ -570,14 +584,32 @@ pub struct GenerateThumbnailsRequest { ), tag = "Thumbnails" )] -pub async fn generate_thumbnails( +pub async fn generate_book_thumbnails( State(state): State>, auth: AuthContext, - Json(request): Json, + Json(request): Json, ) -> Result, ApiError> { // Check permission auth.require_permission(&Permission::TasksWrite)?; + // If library_id provided, verify it exists + if let Some(library_id) = request.library_id { + use crate::db::repositories::LibraryRepository; + LibraryRepository::get_by_id(&state.db, library_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to check library: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Library not found".to_string()))?; + } + + // If series_id provided, verify it exists + if let Some(series_id) = request.series_id { + use crate::db::repositories::SeriesRepository; + SeriesRepository::get_by_id(&state.db, series_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to check series: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Series not found".to_string()))?; + } + let task_type = TaskType::GenerateThumbnails { library_id: request.library_id, series_id: request.series_id, @@ -607,7 +639,7 @@ pub struct ForceRequest { /// - `tasks:write` #[utoipa::path( post, - path = "/api/v1/libraries/{library_id}/thumbnails/generate", + path = "/api/v1/libraries/{library_id}/books/thumbnails/generate", params( ("library_id" = Uuid, Path, description = "Library ID") ), @@ -623,7 +655,7 @@ pub struct ForceRequest { ), tag = "Thumbnails" )] -pub async fn generate_library_thumbnails( +pub async fn generate_library_book_thumbnails( State(state): State>, Path(library_id): Path, auth: AuthContext, @@ -653,23 +685,23 @@ pub async fn generate_library_thumbnails( Ok(Json(CreateTaskResponse { task_id })) } -/// Generate thumbnails for all books in a series +/// Generate thumbnail for a single book /// -/// Queues a fan-out task that enqueues individual thumbnail generation tasks for each book in the series. +/// Queues a task to generate (or regenerate) the thumbnail for a specific book. /// /// # Permission Required /// - `tasks:write` #[utoipa::path( post, - path = "/api/v1/series/{series_id}/thumbnails/generate", + path = "/api/v1/books/{book_id}/thumbnail/generate", params( - ("series_id" = Uuid, Path, description = "Series ID") + ("book_id" = Uuid, Path, description = "Book ID") ), request_body = ForceRequest, responses( (status = 200, description = "Thumbnail generation task queued", body = CreateTaskResponse), (status = 403, description = "Permission denied"), - (status = 404, description = "Series not found"), + (status = 404, description = "Book not found"), ), security( ("bearer_auth" = []), @@ -677,26 +709,25 @@ pub async fn generate_library_thumbnails( ), tag = "Thumbnails" )] -pub async fn generate_series_thumbnails( +pub async fn generate_book_thumbnail( State(state): State>, - Path(series_id): Path, + Path(book_id): Path, auth: AuthContext, Json(request): Json, ) -> Result, ApiError> { - use crate::db::repositories::SeriesRepository; + use crate::db::repositories::BookRepository; // Check permission auth.require_permission(&Permission::TasksWrite)?; - // Verify series exists - SeriesRepository::get_by_id(&state.db, series_id) + // Verify book exists + BookRepository::get_by_id(&state.db, book_id) .await - .map_err(|e| ApiError::Internal(format!("Failed to check series: {}", e)))? - .ok_or_else(|| ApiError::NotFound("Series not found".to_string()))?; + .map_err(|e| ApiError::Internal(format!("Failed to check book: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Book not found".to_string()))?; - let task_type = TaskType::GenerateThumbnails { - library_id: None, - series_id: Some(series_id), + let task_type = TaskType::GenerateThumbnail { + book_id, force: request.force, }; @@ -707,23 +738,24 @@ pub async fn generate_series_thumbnails( Ok(Json(CreateTaskResponse { task_id })) } -/// Generate thumbnail for a single book +/// Generate thumbnail for a series /// -/// Queues a task to generate (or regenerate) the thumbnail for a specific book. +/// Queues a task to generate (or regenerate) the thumbnail for a specific series. +/// The series thumbnail is derived from the first book's cover. /// /// # Permission Required /// - `tasks:write` #[utoipa::path( post, - path = "/api/v1/books/{book_id}/thumbnail/generate", + path = "/api/v1/series/{series_id}/thumbnail/generate", params( - ("book_id" = Uuid, Path, description = "Book ID") + ("series_id" = Uuid, Path, description = "Series ID") ), request_body = ForceRequest, responses( (status = 200, description = "Thumbnail generation task queued", body = CreateTaskResponse), (status = 403, description = "Permission denied"), - (status = 404, description = "Book not found"), + (status = 404, description = "Series not found"), ), security( ("bearer_auth" = []), @@ -731,53 +763,120 @@ pub async fn generate_series_thumbnails( ), tag = "Thumbnails" )] -pub async fn generate_book_thumbnail( +pub async fn generate_series_thumbnail( State(state): State>, - Path(book_id): Path, + Path(series_id): Path, auth: AuthContext, Json(request): Json, ) -> Result, ApiError> { - use crate::db::repositories::BookRepository; + use crate::db::repositories::SeriesRepository; // Check permission auth.require_permission(&Permission::TasksWrite)?; - // Verify book exists - BookRepository::get_by_id(&state.db, book_id) + // Verify series exists + SeriesRepository::get_by_id(&state.db, series_id) .await - .map_err(|e| ApiError::Internal(format!("Failed to check book: {}", e)))? - .ok_or_else(|| ApiError::NotFound("Book not found".to_string()))?; + .map_err(|e| ApiError::Internal(format!("Failed to check series: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Series not found".to_string()))?; - let task_type = TaskType::GenerateThumbnail { - book_id, + let task_type = TaskType::GenerateSeriesThumbnail { + series_id, force: request.force, }; let task_id = TaskRepository::enqueue(&state.db, task_type, 0, None) .await - .map_err(|e| ApiError::Internal(format!("Failed to queue thumbnail generation: {}", e)))?; + .map_err(|e| { + ApiError::Internal(format!( + "Failed to queue series thumbnail generation: {}", + e + )) + })?; Ok(Json(CreateTaskResponse { task_id })) } -/// Generate thumbnail for a series +/// Generate thumbnails for series in a scope /// -/// Queues a task to generate (or regenerate) the thumbnail for a specific series. -/// The series thumbnail is derived from the first book's cover. +/// This queues a fan-out task that enqueues individual series thumbnail generation tasks. +/// Series thumbnails are the cover images displayed for each series (derived from the first book's cover). +/// +/// **Scope:** +/// - If `library_id` is provided, only series in that library +/// - If not provided, all series in all libraries +/// +/// **Force behavior:** +/// - `force: false` (default): Only generates thumbnails for series that don't have one +/// - `force: true`: Regenerates all thumbnails, replacing existing ones /// /// # Permission Required /// - `tasks:write` #[utoipa::path( post, - path = "/api/v1/series/{series_id}/thumbnail/generate", + path = "/api/v1/series/thumbnails/generate", + request_body = GenerateSeriesThumbnailsRequest, + responses( + (status = 200, description = "Series thumbnail generation task queued", body = CreateTaskResponse), + (status = 403, description = "Permission denied"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Thumbnails" +)] +pub async fn generate_series_thumbnails( + State(state): State>, + auth: AuthContext, + Json(request): Json, +) -> Result, ApiError> { + // Check permission + auth.require_permission(&Permission::TasksWrite)?; + + // If library_id provided, verify it exists + if let Some(library_id) = request.library_id { + use crate::db::repositories::LibraryRepository; + LibraryRepository::get_by_id(&state.db, library_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to check library: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Library not found".to_string()))?; + } + + let task_type = TaskType::GenerateSeriesThumbnails { + library_id: request.library_id, + force: request.force, + }; + + let task_id = TaskRepository::enqueue(&state.db, task_type, 0, None) + .await + .map_err(|e| { + ApiError::Internal(format!( + "Failed to queue series thumbnail generation: {}", + e + )) + })?; + + Ok(Json(CreateTaskResponse { task_id })) +} + +/// Generate thumbnails for all series in a library +/// +/// Queues a fan-out task that generates thumbnails for all series in the specified library. +/// +/// # Permission Required +/// - `tasks:write` +#[utoipa::path( + post, + path = "/api/v1/libraries/{library_id}/series/thumbnails/generate", params( - ("series_id" = Uuid, Path, description = "Series ID") + ("library_id" = Uuid, Path, description = "Library ID") ), request_body = ForceRequest, responses( - (status = 200, description = "Thumbnail generation task queued", body = CreateTaskResponse), + (status = 200, description = "Series thumbnail generation task queued", body = CreateTaskResponse), (status = 403, description = "Permission denied"), - (status = 404, description = "Series not found"), + (status = 404, description = "Library not found"), ), security( ("bearer_auth" = []), @@ -785,25 +884,25 @@ pub async fn generate_book_thumbnail( ), tag = "Thumbnails" )] -pub async fn generate_series_thumbnail( +pub async fn generate_library_series_thumbnails( State(state): State>, - Path(series_id): Path, + Path(library_id): Path, auth: AuthContext, Json(request): Json, ) -> Result, ApiError> { - use crate::db::repositories::SeriesRepository; + use crate::db::repositories::LibraryRepository; // Check permission auth.require_permission(&Permission::TasksWrite)?; - // Verify series exists - SeriesRepository::get_by_id(&state.db, series_id) + // Verify library exists + LibraryRepository::get_by_id(&state.db, library_id) .await - .map_err(|e| ApiError::Internal(format!("Failed to check series: {}", e)))? - .ok_or_else(|| ApiError::NotFound("Series not found".to_string()))?; + .map_err(|e| ApiError::Internal(format!("Failed to check library: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Library not found".to_string()))?; - let task_type = TaskType::GenerateSeriesThumbnail { - series_id, + let task_type = TaskType::GenerateSeriesThumbnails { + library_id: Some(library_id), force: request.force, }; diff --git a/src/api/routes/v1/routes/admin.rs b/src/api/routes/v1/routes/admin.rs index 765a6cd1..c1a2f03f 100644 --- a/src/api/routes/v1/routes/admin.rs +++ b/src/api/routes/v1/routes/admin.rs @@ -16,6 +16,7 @@ use std::sync::Arc; /// /// Routes: /// - Settings: /admin/settings +/// - Plugins: /admin/plugins /// - Sharing tags: /admin/sharing-tags /// - Cleanup: /admin/cleanup-orphans pub fn routes(_state: Arc) -> Router> { @@ -89,4 +90,40 @@ pub fn routes(_state: Arc) -> Router> { "/admin/pdf-cache", delete(handlers::pdf_cache::clear_pdf_cache), ) + // Plugin management routes (admin only) + .route("/admin/plugins", get(handlers::plugins::list_plugins)) + .route("/admin/plugins", post(handlers::plugins::create_plugin)) + .route("/admin/plugins/:id", get(handlers::plugins::get_plugin)) + .route( + "/admin/plugins/:id", + patch(handlers::plugins::update_plugin), + ) + .route( + "/admin/plugins/:id", + delete(handlers::plugins::delete_plugin), + ) + .route( + "/admin/plugins/:id/enable", + post(handlers::plugins::enable_plugin), + ) + .route( + "/admin/plugins/:id/disable", + post(handlers::plugins::disable_plugin), + ) + .route( + "/admin/plugins/:id/test", + post(handlers::plugins::test_plugin), + ) + .route( + "/admin/plugins/:id/health", + get(handlers::plugins::get_plugin_health), + ) + .route( + "/admin/plugins/:id/reset", + post(handlers::plugins::reset_plugin_failures), + ) + .route( + "/admin/plugins/:id/failures", + get(handlers::plugins::get_plugin_failures), + ) } diff --git a/src/api/routes/v1/routes/books.rs b/src/api/routes/v1/routes/books.rs index 1f3abb22..283170dc 100644 --- a/src/api/routes/v1/routes/books.rs +++ b/src/api/routes/v1/routes/books.rs @@ -77,9 +77,8 @@ pub fn routes(_state: Arc) -> Router> { "/books/recently-read", get(handlers::list_recently_read_books), ) - .route("/books/with-errors", get(handlers::list_books_with_errors)) - // Enhanced error endpoints (v2 - grouped with retry) - .route("/books/errors", get(handlers::list_books_with_errors_v2)) + // Error endpoints (grouped with retry) + .route("/books/errors", get(handlers::list_books_with_errors)) .route("/books/:book_id/retry", post(handlers::retry_book_errors)) .route( "/books/retry-all-errors", @@ -110,4 +109,11 @@ pub fn routes(_state: Arc) -> Router> { "/books/:book_id/unread", post(handlers::mark_book_as_unread), ) + // Bulk operations + .route("/books/bulk/read", post(handlers::bulk_mark_books_as_read)) + .route( + "/books/bulk/unread", + post(handlers::bulk_mark_books_as_unread), + ) + .route("/books/bulk/analyze", post(handlers::bulk_analyze_books)) } diff --git a/src/api/routes/v1/routes/libraries.rs b/src/api/routes/v1/routes/libraries.rs index 34621fca..1d7f4884 100644 --- a/src/api/routes/v1/routes/libraries.rs +++ b/src/api/routes/v1/routes/libraries.rs @@ -51,10 +51,6 @@ pub fn routes(_state: Arc) -> Router> { "/libraries/:library_id/books/on-deck", get(handlers::list_library_on_deck_books), ) - .route( - "/libraries/:library_id/books/with-errors", - get(handlers::list_library_books_with_errors), - ) .route( "/libraries/:library_id/books/recently-read", get(handlers::list_library_recently_read_books), @@ -95,4 +91,9 @@ pub fn routes(_state: Arc) -> Router> { "/libraries/:library_id/analyze-unanalyzed", post(handlers::trigger_library_unanalyzed_analysis), ) + // Plugin auto-match for library (Phase 5.5) + .route( + "/libraries/:library_id/metadata/auto-match/task", + post(handlers::plugin_actions::enqueue_library_auto_match_tasks), + ) } diff --git a/src/api/routes/v1/routes/misc.rs b/src/api/routes/v1/routes/misc.rs index e99d60e5..715efe6b 100644 --- a/src/api/routes/v1/routes/misc.rs +++ b/src/api/routes/v1/routes/misc.rs @@ -41,6 +41,7 @@ pub fn routes(_state: Arc) -> Router> { .route("/tags/:tag_id", delete(handlers::delete_tag)) // Metrics routes .route("/metrics/inventory", get(handlers::get_inventory_metrics)) + .route("/metrics/plugins", get(handlers::get_plugin_metrics)) .route( "/metrics/tasks", get(handlers::task_metrics::get_task_metrics), diff --git a/src/api/routes/v1/routes/mod.rs b/src/api/routes/v1/routes/mod.rs index 303036e6..76c3ea08 100644 --- a/src/api/routes/v1/routes/mod.rs +++ b/src/api/routes/v1/routes/mod.rs @@ -8,6 +8,7 @@ mod auth; mod books; mod libraries; mod misc; +mod plugins; mod series; mod setup; mod tasks; @@ -33,6 +34,7 @@ pub fn create_router(state: Arc) -> Router { .merge(admin::routes(state.clone())) .merge(tasks::routes(state.clone())) .merge(misc::routes(state.clone())) + .merge(plugins::routes(state.clone())) // Apply state to all routes .with_state(state) } diff --git a/src/api/routes/v1/routes/plugins.rs b/src/api/routes/v1/routes/plugins.rs new file mode 100644 index 00000000..07403f98 --- /dev/null +++ b/src/api/routes/v1/routes/plugins.rs @@ -0,0 +1,30 @@ +//! Plugin routes (user-facing) +//! +//! Handles user-facing plugin operations: +//! - Plugin action discovery +//! - Plugin method execution + +use super::super::handlers; +use crate::api::extractors::AppState; +use axum::{ + routing::{get, post}, + Router, +}; +use std::sync::Arc; + +/// Create plugin routes +/// +/// Routes: +/// - GET /plugins/actions - Get available plugin actions for a scope +/// - POST /plugins/:id/execute - Execute a plugin method +pub fn routes(_state: Arc) -> Router> { + Router::new() + .route( + "/plugins/actions", + get(handlers::plugin_actions::get_plugin_actions), + ) + .route( + "/plugins/:id/execute", + post(handlers::plugin_actions::execute_plugin), + ) +} diff --git a/src/api/routes/v1/routes/series.rs b/src/api/routes/v1/routes/series.rs index 23a446d9..32448281 100644 --- a/src/api/routes/v1/routes/series.rs +++ b/src/api/routes/v1/routes/series.rs @@ -36,10 +36,6 @@ pub fn routes(_state: Arc) -> Router> { .route("/series/:series_id", get(handlers::get_series)) .route("/series/:series_id", patch(handlers::patch_series)) .route("/series/:series_id/books", get(handlers::get_series_books)) - .route( - "/series/:series_id/books/with-errors", - get(handlers::list_series_books_with_errors), - ) // Series collection routes .route( "/series/in-progress", @@ -62,10 +58,7 @@ pub fn routes(_state: Arc) -> Router> { "/series/:series_id/thumbnail", get(handlers::get_series_thumbnail), ) - .route( - "/series/:series_id/thumbnail/generate", - post(handlers::generate_series_thumbnail), - ) + // Note: POST /series/:series_id/thumbnail/generate is in tasks.rs .route( "/series/:series_id/cover", post(handlers::upload_series_cover), @@ -242,4 +235,36 @@ pub fn routes(_state: Arc) -> Router> { "/series/:series_id/unread", post(handlers::mark_series_as_unread), ) + // Bulk operations + .route( + "/series/bulk/read", + post(handlers::bulk_mark_series_as_read), + ) + .route( + "/series/bulk/unread", + post(handlers::bulk_mark_series_as_unread), + ) + .route("/series/bulk/analyze", post(handlers::bulk_analyze_series)) + // Series metadata from plugins (Phase 4) + .route( + "/series/:series_id/metadata/preview", + post(handlers::plugin_actions::preview_series_metadata), + ) + .route( + "/series/:series_id/metadata/apply", + post(handlers::plugin_actions::apply_series_metadata), + ) + .route( + "/series/:series_id/metadata/auto-match", + post(handlers::plugin_actions::auto_match_series_metadata), + ) + // Task-based auto-match endpoints (Phase 5.5 - Worker plugin integration) + .route( + "/series/:series_id/metadata/auto-match/task", + post(handlers::plugin_actions::enqueue_auto_match_task), + ) + .route( + "/series/metadata/auto-match/task/bulk", + post(handlers::plugin_actions::enqueue_bulk_auto_match_tasks), + ) } diff --git a/src/api/routes/v1/routes/tasks.rs b/src/api/routes/v1/routes/tasks.rs index b01193a0..8e9c6ff0 100644 --- a/src/api/routes/v1/routes/tasks.rs +++ b/src/api/routes/v1/routes/tasks.rs @@ -17,8 +17,9 @@ use std::sync::Arc; /// Routes: /// - Tasks: /tasks, /tasks/:id, /tasks/stats /// - Task operations: /tasks/:id/cancel, /tasks/:id/retry, /tasks/:id/unlock -/// - Thumbnails: /thumbnails/generate, /libraries/:id/thumbnails/generate, etc. /// - Task stream: /tasks/stream +/// - Book thumbnails: /books/thumbnails/generate, /books/:id/thumbnail/generate, /libraries/:id/books/thumbnails/generate +/// - Series thumbnails: /series/thumbnails/generate, /series/:id/thumbnail/generate, /libraries/:id/series/thumbnails/generate pub fn routes(_state: Arc) -> Router> { Router::new() // Task Queue routes - distributed task queue @@ -45,21 +46,30 @@ pub fn routes(_state: Arc) -> Router> { .route("/tasks/nuke", delete(handlers::task_queue::nuke_all_tasks)) // Task progress stream .route("/tasks/stream", get(handlers::task_progress_stream)) - // Thumbnail generation routes + // Book thumbnail generation routes .route( - "/thumbnails/generate", - post(handlers::task_queue::generate_thumbnails), + "/books/thumbnails/generate", + post(handlers::task_queue::generate_book_thumbnails), ) .route( - "/libraries/:library_id/thumbnails/generate", - post(handlers::task_queue::generate_library_thumbnails), + "/books/:book_id/thumbnail/generate", + post(handlers::task_queue::generate_book_thumbnail), + ) + .route( + "/libraries/:library_id/books/thumbnails/generate", + post(handlers::task_queue::generate_library_book_thumbnails), ) + // Series thumbnail generation routes .route( - "/series/:series_id/thumbnails/generate", + "/series/thumbnails/generate", post(handlers::task_queue::generate_series_thumbnails), ) .route( - "/books/:book_id/thumbnail/generate", - post(handlers::task_queue::generate_book_thumbnail), + "/series/:series_id/thumbnail/generate", + post(handlers::task_queue::generate_series_thumbnail), + ) + .route( + "/libraries/:library_id/series/thumbnails/generate", + post(handlers::task_queue::generate_library_series_thumbnails), ) } diff --git a/src/commands/common.rs b/src/commands/common.rs index f1cdfa9b..7e3a83a4 100644 --- a/src/commands/common.rs +++ b/src/commands/common.rs @@ -406,6 +406,7 @@ pub fn spawn_workers( task_metrics_service: Option>, files_config: crate::config::FilesConfig, pdf_page_cache: Option>, + plugin_manager: Option>, ) -> ( Vec>, Vec>, @@ -439,6 +440,11 @@ pub fn spawn_workers( task_worker = task_worker.with_pdf_cache(pdf_cache.clone(), settings_service.clone()); } + // Add plugin manager if available (for plugin auto-match tasks) + if let Some(ref pm) = plugin_manager { + task_worker = task_worker.with_plugin_manager(pm.clone()); + } + let (mut task_worker, worker_shutdown_tx) = task_worker.with_shutdown(); worker_shutdown_channels.push(worker_shutdown_tx); diff --git a/src/commands/serve.rs b/src/commands/serve.rs index 96d62f9b..e015fc22 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -43,15 +43,6 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { // Initialize database connection let db = init_database(&config).await?; - // Create and start scheduler - info!("Initializing job scheduler..."); - let scheduler: Arc> = - Arc::new(tokio::sync::Mutex::new( - crate::scheduler::Scheduler::new(db.sea_orm_connection().clone()).await?, - )); - scheduler.lock().await.start().await?; - info!("Job scheduler started successfully"); - // Create cancellation token for graceful shutdown of background tasks let background_task_cancel = CancellationToken::new(); @@ -102,13 +93,22 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { .map(|v| v.eq_ignore_ascii_case("true") || v == "1") .unwrap_or(false); - // Initialize thumbnail service (needed for both workers and API handlers) + // Initialize thumbnail service (needed for both workers, API handlers, and scheduler) let thumbnail_service = Arc::new(crate::services::ThumbnailService::new(config.files.clone())); info!( "Files service initialized (thumbnails: {}, uploads: {})", config.files.thumbnail_dir, config.files.uploads_dir ); + // Create and start scheduler + info!("Initializing job scheduler..."); + let scheduler: Arc> = + Arc::new(tokio::sync::Mutex::new( + crate::scheduler::Scheduler::new(db.sea_orm_connection().clone()).await?, + )); + scheduler.lock().await.start().await?; + info!("Job scheduler started successfully"); + // Initialize file cleanup service (for orphaned file cleanup via API) let file_cleanup_service = Arc::new(crate::services::FileCleanupService::new( config.files.clone(), @@ -210,6 +210,37 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { .start_background_cleanup(background_task_cancel.clone()) }); + // Initialize email service + info!("Initializing email service..."); + let email_service = Arc::new(crate::services::email::EmailService::new( + config.email.clone(), + )); + info!(" SMTP host: {}", config.email.smtp_host); + info!(" SMTP port: {}", config.email.smtp_port); + info!(" From: {}", config.email.smtp_from_email); + + // Initialize plugin metrics service + info!("Initializing plugin metrics service..."); + let plugin_metrics_service = Arc::new(crate::services::PluginMetricsService::new()); + info!("Plugin metrics service initialized"); + + // Initialize plugin manager (before workers so they can handle plugin tasks) + info!("Initializing plugin manager..."); + let plugin_manager = Arc::new( + crate::services::plugin::PluginManager::with_defaults(Arc::new( + db.sea_orm_connection().clone(), + )) + .with_metrics_service(plugin_metrics_service.clone()), + ); + // Load enabled plugins from database + match plugin_manager.load_all().await { + Ok(count) => info!(" Loaded {} enabled plugins", count), + Err(e) => tracing::warn!(" Failed to load plugins: {}", e), + } + // Start periodic health checks for plugins + plugin_manager.start_health_checks().await; + info!(" Plugin health checks started (60s interval)"); + // Initialize worker tracking variables let mut worker_handles = Vec::new(); let mut worker_shutdown_channels = Vec::new(); @@ -242,6 +273,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { Some(task_metrics_service.clone()), config.files.clone(), Some(pdf_page_cache.clone()), + Some(plugin_manager.clone()), ); worker_handles = handles; worker_shutdown_channels = channels; @@ -249,15 +281,6 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { info!("All {} task workers started successfully", worker_count); } - // Initialize email service - info!("Initializing email service..."); - let email_service = Arc::new(crate::services::email::EmailService::new( - config.email.clone(), - )); - info!(" SMTP host: {}", config.email.smtp_host); - info!(" SMTP port: {}", config.email.smtp_port); - info!(" From: {}", config.email.smtp_from_email); - // Create application state for API let api_state = Arc::new(crate::api::AppState { db: db.sea_orm_connection().clone(), @@ -285,6 +308,8 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { inflight_thumbnails: Arc::new(crate::services::InflightThumbnailTracker::new()), user_auth_cache: Arc::new(crate::api::extractors::auth::UserAuthCache::new()), rate_limiter_service, + plugin_manager: plugin_manager.clone(), + plugin_metrics_service, }); // Build router using API module @@ -387,6 +412,11 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { tracing::warn!("Failed to shutdown scheduler gracefully: {}", e); } + // Shutdown plugin manager (stops health checks and all plugins) + info!("Shutting down plugin manager..."); + plugin_manager.shutdown_all().await; + info!("Plugin manager shutdown complete"); + // Shutdown workers if they were started if !disable_workers && worker_count > 0 { shutdown_workers(worker_handles, worker_shutdown_channels, worker_count).await; diff --git a/src/commands/worker.rs b/src/commands/worker.rs index 8769e3d3..364c3904 100644 --- a/src/commands/worker.rs +++ b/src/commands/worker.rs @@ -111,6 +111,27 @@ pub async fn worker_command(config_path: PathBuf) -> anyhow::Result<()> { } } + // Initialize plugin metrics service for plugin operation metrics + info!("Initializing plugin metrics service..."); + let plugin_metrics_service = Arc::new(crate::services::PluginMetricsService::new()); + + // Initialize plugin manager for plugin auto-match tasks + info!("Initializing plugin manager..."); + let plugin_manager = Arc::new( + crate::services::plugin::PluginManager::with_defaults(Arc::new( + db.sea_orm_connection().clone(), + )) + .with_metrics_service(plugin_metrics_service), + ); + // Load enabled plugins from database + match plugin_manager.load_all().await { + Ok(count) => info!(" Loaded {} enabled plugins", count), + Err(e) => tracing::warn!(" Failed to load plugins: {}", e), + } + // Start periodic health checks for plugins + plugin_manager.start_health_checks().await; + info!(" Plugin health checks started (60s interval)"); + // Spawn multiple workers for parallel task processing let (worker_handles, worker_shutdown_channels) = spawn_workers( db.sea_orm_connection(), @@ -121,6 +142,7 @@ pub async fn worker_command(config_path: PathBuf) -> anyhow::Result<()> { Some(task_metrics_service), config.files.clone(), Some(pdf_page_cache), + Some(plugin_manager), ); info!("All {} task workers started successfully", worker_count); diff --git a/src/db/entities/mod.rs b/src/db/entities/mod.rs index c8d26ccb..f5715236 100644 --- a/src/db/entities/mod.rs +++ b/src/db/entities/mod.rs @@ -2,6 +2,9 @@ pub mod prelude; +// Re-export common enums +pub use series_metadata::SeriesStatus; + // Core entities pub mod api_keys; pub mod book_duplicates; @@ -12,6 +15,8 @@ pub mod email_verification_tokens; pub mod libraries; pub mod metadata_sources; pub mod pages; +pub mod plugin_failures; +pub mod plugins; pub mod read_progress; pub mod series; pub mod settings; diff --git a/src/db/entities/plugin_failures.rs b/src/db/entities/plugin_failures.rs new file mode 100644 index 00000000..83fa1dd3 --- /dev/null +++ b/src/db/entities/plugin_failures.rs @@ -0,0 +1,120 @@ +//! Plugin failure entity for time-windowed failure tracking +//! +//! This entity stores individual failure events for plugins, enabling: +//! - Time-windowed failure counting (e.g., 3 failures in 1 hour triggers auto-disable) +//! - Error message storage for debugging +//! - Automatic expiration and cleanup of old failures + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "plugin_failures")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + + /// Reference to the plugin that failed + pub plugin_id: Uuid, + + /// Human-readable error message + pub error_message: String, + + /// Error code for categorization (e.g., "TIMEOUT", "PROCESS_CRASHED", "RPC_ERROR") + pub error_code: Option, + + /// Which method failed (e.g., "initialize", "metadata/search", "shutdown") + pub method: Option, + + /// JSON-RPC request ID if applicable + pub request_id: Option, + + /// Additional context (parameters, stack trace, etc.) + pub context: Option, + + /// Sanitized summary of request parameters (sensitive fields redacted) + pub request_summary: Option, + + /// When the failure occurred + pub occurred_at: DateTime, + + /// When this failure record expires and should be deleted + /// Default: 30 days from occurred_at + pub expires_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::plugins::Entity", + from = "Column::PluginId", + to = "super::plugins::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Plugin, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Plugin.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} + +// ============================================================================= +// Error Code Constants +// ============================================================================= + +/// Common error codes for plugin failures +/// +/// These constants are provided for consistency when recording plugin failures. +/// Not all are used internally - they're available for plugin implementations. +#[allow(dead_code)] +pub mod error_codes { + /// Plugin process did not respond within timeout + pub const TIMEOUT: &str = "TIMEOUT"; + + /// Plugin process crashed or was terminated + pub const PROCESS_CRASHED: &str = "PROCESS_CRASHED"; + + /// JSON-RPC communication error + pub const RPC_ERROR: &str = "RPC_ERROR"; + + /// Plugin returned an error response + pub const PLUGIN_ERROR: &str = "PLUGIN_ERROR"; + + /// Failed to spawn the plugin process + pub const SPAWN_ERROR: &str = "SPAWN_ERROR"; + + /// Plugin initialization failed + pub const INIT_ERROR: &str = "INIT_ERROR"; + + /// Plugin returned invalid response format + pub const INVALID_RESPONSE: &str = "INVALID_RESPONSE"; + + /// Network error when plugin made external requests + pub const NETWORK_ERROR: &str = "NETWORK_ERROR"; + + /// Plugin encountered an internal error + pub const INTERNAL_ERROR: &str = "INTERNAL_ERROR"; +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_codes() { + assert_eq!(error_codes::TIMEOUT, "TIMEOUT"); + assert_eq!(error_codes::PROCESS_CRASHED, "PROCESS_CRASHED"); + assert_eq!(error_codes::RPC_ERROR, "RPC_ERROR"); + } +} diff --git a/src/db/entities/plugins.rs b/src/db/entities/plugins.rs new file mode 100644 index 00000000..36abd357 --- /dev/null +++ b/src/db/entities/plugins.rs @@ -0,0 +1,781 @@ +//! Plugin entity for external metadata provider processes +//! +//! Plugins are external processes that communicate with Codex via JSON-RPC over stdio. +//! This entity stores plugin configuration, RBAC permissions, scopes, and health status. +//! +//! ## Key Features +//! +//! - **Execution**: Command, args, env, working directory for spawning plugin process +//! - **RBAC Permissions**: Controls what metadata fields a plugin can write +//! - **Scopes**: Defines where the plugin can be invoked (series:detail, series:bulk, etc.) +//! - **Credentials**: Encrypted storage for API keys and tokens +//! - **Health Tracking**: Failure count, auto-disable on repeated failures +//! +//! TODO: Remove allow(dead_code) once plugin features are fully implemented + +#![allow(dead_code)] + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "plugins")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + /// Unique identifier (e.g., "mangabaka") + pub name: String, + /// Display name for UI (e.g., "MangaBaka") + pub display_name: String, + /// Description of the plugin + pub description: Option, + /// Plugin type: "system" (admin-configured) or "user" (per-user instances) + pub plugin_type: String, + + // Execution + /// Command to spawn the plugin (e.g., "node", "python", "/path/to/binary") + pub command: String, + /// Command arguments as JSON array (e.g., ["/opt/codex/plugins/mangabaka/dist/index.js"]) + pub args: serde_json::Value, + /// Additional environment variables as JSON object + pub env: serde_json::Value, + /// Working directory for the plugin process + pub working_directory: Option, + + // Permissions & Scopes + /// RBAC permissions as JSON array (e.g., ["metadata:write:summary", "metadata:write:genres"]) + pub permissions: serde_json::Value, + /// Scopes where plugin can be invoked as JSON array (e.g., ["series:detail", "series:bulk"]) + pub scopes: serde_json::Value, + + // Library filtering + /// Library IDs this plugin applies to as JSON array of UUIDs + /// Empty array = all libraries, non-empty = only these specific libraries + /// Use case: Different metadata providers for manga vs comics vs ebooks + pub library_ids: serde_json::Value, + + // Credentials + /// Encrypted credentials (API keys, tokens) + #[serde(skip_serializing)] // Never serialize credentials + pub credentials: Option>, + /// How to deliver credentials to the plugin: "env", "init_message", or "both" + pub credential_delivery: String, + + // Configuration + /// Plugin-specific configuration as JSON object + pub config: serde_json::Value, + /// Cached manifest from plugin (populated after first connection) + pub manifest: Option, + + // State + /// Whether the plugin is enabled + pub enabled: bool, + /// Current health status: "unknown", "healthy", "degraded", "unhealthy", "disabled" + pub health_status: String, + /// Number of consecutive failures + pub failure_count: i32, + /// When the last failure occurred + pub last_failure_at: Option>, + /// When the last successful operation occurred + pub last_success_at: Option>, + /// Reason the plugin was disabled (e.g., "Disabled after 3 consecutive failures") + pub disabled_reason: Option, + + // Rate Limiting + /// Maximum requests per minute (default: 60, None = no limit) + pub rate_limit_requests_per_minute: Option, + + // Timestamps + pub created_at: DateTime, + pub updated_at: DateTime, + pub created_by: Option, + pub updated_by: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::CreatedBy", + to = "super::users::Column::Id", + on_update = "NoAction", + on_delete = "SetNull" + )] + CreatedByUser, + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::UpdatedBy", + to = "super::users::Column::Id", + on_update = "NoAction", + on_delete = "SetNull" + )] + UpdatedByUser, + #[sea_orm(has_many = "super::plugin_failures::Entity")] + Failures, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Failures.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} + +// ============================================================================= +// Health Status Enum +// ============================================================================= + +/// Health status values for plugin health checks +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginHealthStatus { + /// Initial state, not yet checked + #[default] + Unknown, + /// Plugin is working correctly + Healthy, + /// Plugin has some issues but is operational + Degraded, + /// Plugin is not functioning + Unhealthy, + /// Plugin was disabled due to failures or by admin + Disabled, +} + +impl PluginHealthStatus { + pub fn as_str(&self) -> &'static str { + match self { + PluginHealthStatus::Unknown => "unknown", + PluginHealthStatus::Healthy => "healthy", + PluginHealthStatus::Degraded => "degraded", + PluginHealthStatus::Unhealthy => "unhealthy", + PluginHealthStatus::Disabled => "disabled", + } + } +} + +impl FromStr for PluginHealthStatus { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "unknown" => Ok(PluginHealthStatus::Unknown), + "healthy" => Ok(PluginHealthStatus::Healthy), + "degraded" => Ok(PluginHealthStatus::Degraded), + "unhealthy" => Ok(PluginHealthStatus::Unhealthy), + "disabled" => Ok(PluginHealthStatus::Disabled), + _ => Err(format!("Unknown plugin health status: {}", s)), + } + } +} + +impl std::fmt::Display for PluginHealthStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +// ============================================================================= +// Credential Delivery Enum +// ============================================================================= + +/// How credentials are delivered to the plugin +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CredentialDelivery { + /// Pass credentials as environment variables + #[default] + Env, + /// Pass credentials in the initialize message + InitMessage, + /// Pass credentials both ways + Both, +} + +impl CredentialDelivery { + pub fn as_str(&self) -> &'static str { + match self { + CredentialDelivery::Env => "env", + CredentialDelivery::InitMessage => "init_message", + CredentialDelivery::Both => "both", + } + } +} + +impl FromStr for CredentialDelivery { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "env" => Ok(CredentialDelivery::Env), + "init_message" => Ok(CredentialDelivery::InitMessage), + "both" => Ok(CredentialDelivery::Both), + _ => Err(format!("Unknown credential delivery: {}", s)), + } + } +} + +impl std::fmt::Display for CredentialDelivery { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +// ============================================================================= +// Plugin Type Enum +// ============================================================================= + +/// Type of plugin determining who manages it +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginType { + /// Admin-configured plugin for metadata fetching (shared across all users) + #[default] + System, + /// User-configured plugin for sync/recommendations (per-user instances) + User, +} + +impl PluginType { + pub fn as_str(&self) -> &'static str { + match self { + PluginType::System => "system", + PluginType::User => "user", + } + } +} + +impl FromStr for PluginType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "system" => Ok(PluginType::System), + "user" => Ok(PluginType::User), + _ => Err(format!("Unknown plugin type: {}", s)), + } + } +} + +impl std::fmt::Display for PluginType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +// ============================================================================= +// RBAC Permission Enum +// ============================================================================= + +/// RBAC permissions for plugin metadata writes +/// +/// These permissions control what metadata fields a plugin can write. +/// Configured by admin when setting up the plugin. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginPermission { + /// Read series/book metadata + /// + /// Includes: title, summary, genres, tags, year, status, authors, artists, + /// publisher, external ratings (from providers), and user average rating. + /// Does NOT include individual user's personal ratings or notes - those + /// require user-level permissions (`user:ratings:read`, `user:notes:read`). + #[serde(rename = "metadata:read")] + MetadataRead, + /// Update series/book titles + #[serde(rename = "metadata:write:title")] + MetadataWriteTitle, + /// Update summaries/descriptions + #[serde(rename = "metadata:write:summary")] + MetadataWriteSummary, + /// Update genres + #[serde(rename = "metadata:write:genres")] + MetadataWriteGenres, + /// Update tags + #[serde(rename = "metadata:write:tags")] + MetadataWriteTags, + /// Update cover images + #[serde(rename = "metadata:write:covers")] + MetadataWriteCovers, + /// Write external ratings + #[serde(rename = "metadata:write:ratings")] + MetadataWriteRatings, + /// Add external links + #[serde(rename = "metadata:write:links")] + MetadataWriteLinks, + /// Update publication year + #[serde(rename = "metadata:write:year")] + MetadataWriteYear, + /// Update publication status + #[serde(rename = "metadata:write:status")] + MetadataWriteStatus, + /// Update publisher + #[serde(rename = "metadata:write:publisher")] + MetadataWritePublisher, + /// Update age rating + #[serde(rename = "metadata:write:age_rating")] + MetadataWriteAgeRating, + /// Update language + #[serde(rename = "metadata:write:language")] + MetadataWriteLanguage, + /// Update reading direction + #[serde(rename = "metadata:write:reading_direction")] + MetadataWriteReadingDirection, + /// Update total book count + #[serde(rename = "metadata:write:total_book_count")] + MetadataWriteTotalBookCount, + /// All metadata write permissions + #[serde(rename = "metadata:write:*")] + MetadataWriteAll, + /// Read library structure + #[serde(rename = "library:read")] + LibraryRead, +} + +impl PluginPermission { + pub fn as_str(&self) -> &'static str { + match self { + PluginPermission::MetadataRead => "metadata:read", + PluginPermission::MetadataWriteTitle => "metadata:write:title", + PluginPermission::MetadataWriteSummary => "metadata:write:summary", + PluginPermission::MetadataWriteGenres => "metadata:write:genres", + PluginPermission::MetadataWriteTags => "metadata:write:tags", + PluginPermission::MetadataWriteCovers => "metadata:write:covers", + PluginPermission::MetadataWriteRatings => "metadata:write:ratings", + PluginPermission::MetadataWriteLinks => "metadata:write:links", + PluginPermission::MetadataWriteYear => "metadata:write:year", + PluginPermission::MetadataWriteStatus => "metadata:write:status", + PluginPermission::MetadataWritePublisher => "metadata:write:publisher", + PluginPermission::MetadataWriteAgeRating => "metadata:write:age_rating", + PluginPermission::MetadataWriteLanguage => "metadata:write:language", + PluginPermission::MetadataWriteReadingDirection => "metadata:write:reading_direction", + PluginPermission::MetadataWriteTotalBookCount => "metadata:write:total_book_count", + PluginPermission::MetadataWriteAll => "metadata:write:*", + PluginPermission::LibraryRead => "library:read", + } + } + + /// Get all individual write permissions that "metadata:write:*" expands to + pub fn all_write_permissions() -> Vec { + vec![ + PluginPermission::MetadataWriteTitle, + PluginPermission::MetadataWriteSummary, + PluginPermission::MetadataWriteGenres, + PluginPermission::MetadataWriteTags, + PluginPermission::MetadataWriteCovers, + PluginPermission::MetadataWriteRatings, + PluginPermission::MetadataWriteLinks, + PluginPermission::MetadataWriteYear, + PluginPermission::MetadataWriteStatus, + PluginPermission::MetadataWritePublisher, + PluginPermission::MetadataWriteAgeRating, + PluginPermission::MetadataWriteLanguage, + PluginPermission::MetadataWriteReadingDirection, + PluginPermission::MetadataWriteTotalBookCount, + ] + } +} + +impl FromStr for PluginPermission { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "metadata:read" => Ok(PluginPermission::MetadataRead), + "metadata:write:title" => Ok(PluginPermission::MetadataWriteTitle), + "metadata:write:summary" => Ok(PluginPermission::MetadataWriteSummary), + "metadata:write:genres" => Ok(PluginPermission::MetadataWriteGenres), + "metadata:write:tags" => Ok(PluginPermission::MetadataWriteTags), + "metadata:write:covers" => Ok(PluginPermission::MetadataWriteCovers), + "metadata:write:ratings" => Ok(PluginPermission::MetadataWriteRatings), + "metadata:write:links" => Ok(PluginPermission::MetadataWriteLinks), + "metadata:write:year" => Ok(PluginPermission::MetadataWriteYear), + "metadata:write:status" => Ok(PluginPermission::MetadataWriteStatus), + "metadata:write:publisher" => Ok(PluginPermission::MetadataWritePublisher), + "metadata:write:age_rating" => Ok(PluginPermission::MetadataWriteAgeRating), + "metadata:write:language" => Ok(PluginPermission::MetadataWriteLanguage), + "metadata:write:reading_direction" => { + Ok(PluginPermission::MetadataWriteReadingDirection) + } + "metadata:write:total_book_count" => Ok(PluginPermission::MetadataWriteTotalBookCount), + "metadata:write:*" => Ok(PluginPermission::MetadataWriteAll), + "library:read" => Ok(PluginPermission::LibraryRead), + _ => Err(format!("Unknown plugin permission: {}", s)), + } + } +} + +impl std::fmt::Display for PluginPermission { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +// ============================================================================= +// Helper Methods +// ============================================================================= + +impl Model { + /// Parse the args JSON array into a Vec + pub fn args_vec(&self) -> Vec { + self.args + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default() + } + + /// Parse the env JSON object into a Vec<(String, String)> + pub fn env_vec(&self) -> Vec<(String, String)> { + self.env + .as_object() + .map(|obj| { + obj.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default() + } + + /// Parse the permissions JSON array into a Vec + pub fn permissions_vec(&self) -> Vec { + self.permissions + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().and_then(|s| PluginPermission::from_str(s).ok())) + .collect() + }) + .unwrap_or_default() + } + + /// Check if the plugin has a specific permission + pub fn has_permission(&self, permission: &PluginPermission) -> bool { + let permissions = self.permissions_vec(); + + // Check for wildcard permission + if permissions.contains(&PluginPermission::MetadataWriteAll) { + // Wildcard grants all write permissions + if matches!( + permission, + PluginPermission::MetadataWriteTitle + | PluginPermission::MetadataWriteSummary + | PluginPermission::MetadataWriteGenres + | PluginPermission::MetadataWriteTags + | PluginPermission::MetadataWriteCovers + | PluginPermission::MetadataWriteRatings + | PluginPermission::MetadataWriteLinks + | PluginPermission::MetadataWriteYear + | PluginPermission::MetadataWriteStatus + | PluginPermission::MetadataWritePublisher + | PluginPermission::MetadataWriteAgeRating + | PluginPermission::MetadataWriteLanguage + | PluginPermission::MetadataWriteReadingDirection + | PluginPermission::MetadataWriteTotalBookCount + ) { + return true; + } + } + + permissions.contains(permission) + } + + /// Parse the scopes JSON array into a Vec + pub fn scopes_vec(&self) -> Vec { + use crate::services::plugin::protocol::PluginScope; + + self.scopes + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| serde_json::from_value::(v.clone()).ok()) + .collect() + }) + .unwrap_or_default() + } + + /// Check if the plugin supports a specific scope + pub fn has_scope(&self, scope: &crate::services::plugin::protocol::PluginScope) -> bool { + self.scopes_vec().contains(scope) + } + + /// Parse the library_ids JSON array into a Vec + pub fn library_ids_vec(&self) -> Vec { + self.library_ids + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().and_then(|s| Uuid::parse_str(s).ok())) + .collect() + }) + .unwrap_or_default() + } + + /// Check if the plugin applies to a specific library + /// Returns true if library_ids is empty (applies to all) or contains the given library_id + pub fn applies_to_library(&self, library_id: Uuid) -> bool { + let library_ids = self.library_ids_vec(); + library_ids.is_empty() || library_ids.contains(&library_id) + } + + /// Check if the plugin applies to all libraries (no restrictions) + pub fn applies_to_all_libraries(&self) -> bool { + self.library_ids_vec().is_empty() + } + + /// Parse plugin type + pub fn plugin_type_enum(&self) -> PluginType { + PluginType::from_str(&self.plugin_type).unwrap_or_default() + } + + /// Check if this is a system plugin (admin-configured) + pub fn is_system_plugin(&self) -> bool { + self.plugin_type_enum() == PluginType::System + } + + /// Check if this is a user plugin (per-user instances) + pub fn is_user_plugin(&self) -> bool { + self.plugin_type_enum() == PluginType::User + } + + /// Parse credential delivery type + pub fn credential_delivery_type(&self) -> CredentialDelivery { + CredentialDelivery::from_str(&self.credential_delivery).unwrap_or_default() + } + + /// Parse health status + pub fn health_status_type(&self) -> PluginHealthStatus { + PluginHealthStatus::from_str(&self.health_status).unwrap_or_default() + } + + /// Check if the plugin has credentials configured + pub fn has_credentials(&self) -> bool { + self.credentials.is_some() + } + + /// Check if the plugin is in a healthy state (enabled and healthy) + pub fn is_healthy(&self) -> bool { + self.enabled + && matches!( + self.health_status_type(), + PluginHealthStatus::Healthy | PluginHealthStatus::Unknown + ) + } + + /// Get the cached manifest if available + pub fn cached_manifest(&self) -> Option { + self.manifest + .as_ref() + .and_then(|m| serde_json::from_value(m.clone()).ok()) + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_plugin_health_status_as_str() { + assert_eq!(PluginHealthStatus::Unknown.as_str(), "unknown"); + assert_eq!(PluginHealthStatus::Healthy.as_str(), "healthy"); + assert_eq!(PluginHealthStatus::Disabled.as_str(), "disabled"); + } + + #[test] + fn test_plugin_health_status_from_str() { + assert_eq!( + PluginHealthStatus::from_str("healthy").unwrap(), + PluginHealthStatus::Healthy + ); + assert_eq!( + PluginHealthStatus::from_str("disabled").unwrap(), + PluginHealthStatus::Disabled + ); + assert!(PluginHealthStatus::from_str("invalid").is_err()); + } + + #[test] + fn test_credential_delivery_as_str() { + assert_eq!(CredentialDelivery::Env.as_str(), "env"); + assert_eq!(CredentialDelivery::InitMessage.as_str(), "init_message"); + assert_eq!(CredentialDelivery::Both.as_str(), "both"); + } + + #[test] + fn test_credential_delivery_from_str() { + assert_eq!( + CredentialDelivery::from_str("env").unwrap(), + CredentialDelivery::Env + ); + assert_eq!( + CredentialDelivery::from_str("init_message").unwrap(), + CredentialDelivery::InitMessage + ); + assert!(CredentialDelivery::from_str("invalid").is_err()); + } + + #[test] + fn test_plugin_type_as_str() { + assert_eq!(PluginType::System.as_str(), "system"); + assert_eq!(PluginType::User.as_str(), "user"); + } + + #[test] + fn test_plugin_type_from_str() { + assert_eq!(PluginType::from_str("system").unwrap(), PluginType::System); + assert_eq!(PluginType::from_str("user").unwrap(), PluginType::User); + assert!(PluginType::from_str("invalid").is_err()); + } + + #[test] + fn test_plugin_type_default() { + assert_eq!(PluginType::default(), PluginType::System); + } + + #[test] + fn test_plugin_permission_as_str() { + assert_eq!(PluginPermission::MetadataRead.as_str(), "metadata:read"); + assert_eq!( + PluginPermission::MetadataWriteTitle.as_str(), + "metadata:write:title" + ); + assert_eq!( + PluginPermission::MetadataWriteAll.as_str(), + "metadata:write:*" + ); + } + + #[test] + fn test_plugin_permission_from_str() { + assert_eq!( + PluginPermission::from_str("metadata:read").unwrap(), + PluginPermission::MetadataRead + ); + assert_eq!( + PluginPermission::from_str("metadata:write:summary").unwrap(), + PluginPermission::MetadataWriteSummary + ); + assert_eq!( + PluginPermission::from_str("metadata:write:*").unwrap(), + PluginPermission::MetadataWriteAll + ); + assert!(PluginPermission::from_str("invalid").is_err()); + } + + #[test] + fn test_plugin_permission_serialization() { + let perm = PluginPermission::MetadataWriteTitle; + let json = serde_json::to_string(&perm).unwrap(); + assert_eq!(json, "\"metadata:write:title\""); + + let perm: PluginPermission = serde_json::from_str("\"metadata:write:genres\"").unwrap(); + assert_eq!(perm, PluginPermission::MetadataWriteGenres); + } + + #[test] + fn test_all_write_permissions() { + let perms = PluginPermission::all_write_permissions(); + assert!(perms.contains(&PluginPermission::MetadataWriteTitle)); + assert!(perms.contains(&PluginPermission::MetadataWriteSummary)); + assert!(!perms.contains(&PluginPermission::MetadataWriteAll)); + assert!(!perms.contains(&PluginPermission::MetadataRead)); + } + + #[test] + fn test_library_ids_vec_empty() { + use chrono::Utc; + let model = Model { + id: Uuid::new_v4(), + name: "test".to_string(), + display_name: "Test".to_string(), + description: None, + plugin_type: "system".to_string(), + command: "node".to_string(), + args: serde_json::json!([]), + env: serde_json::json!({}), + working_directory: None, + permissions: serde_json::json!([]), + scopes: serde_json::json!([]), + library_ids: serde_json::json!([]), + credentials: None, + credential_delivery: "env".to_string(), + config: serde_json::json!({}), + manifest: None, + enabled: true, + health_status: "healthy".to_string(), + failure_count: 0, + last_failure_at: None, + last_success_at: None, + disabled_reason: None, + rate_limit_requests_per_minute: Some(60), + created_at: Utc::now(), + updated_at: Utc::now(), + created_by: None, + updated_by: None, + }; + + assert!(model.library_ids_vec().is_empty()); + assert!(model.applies_to_all_libraries()); + // Empty library_ids means applies to all libraries + assert!(model.applies_to_library(Uuid::new_v4())); + } + + #[test] + fn test_library_ids_vec_with_values() { + use chrono::Utc; + let lib1 = Uuid::new_v4(); + let lib2 = Uuid::new_v4(); + let lib3 = Uuid::new_v4(); + + let model = Model { + id: Uuid::new_v4(), + name: "test".to_string(), + display_name: "Test".to_string(), + description: None, + plugin_type: "system".to_string(), + command: "node".to_string(), + args: serde_json::json!([]), + env: serde_json::json!({}), + working_directory: None, + permissions: serde_json::json!([]), + scopes: serde_json::json!([]), + library_ids: serde_json::json!([lib1.to_string(), lib2.to_string()]), + credentials: None, + credential_delivery: "env".to_string(), + config: serde_json::json!({}), + manifest: None, + enabled: true, + health_status: "healthy".to_string(), + failure_count: 0, + last_failure_at: None, + last_success_at: None, + disabled_reason: None, + rate_limit_requests_per_minute: Some(60), + created_at: Utc::now(), + updated_at: Utc::now(), + created_by: None, + updated_by: None, + }; + + let library_ids = model.library_ids_vec(); + assert_eq!(library_ids.len(), 2); + assert!(library_ids.contains(&lib1)); + assert!(library_ids.contains(&lib2)); + + assert!(!model.applies_to_all_libraries()); + assert!(model.applies_to_library(lib1)); + assert!(model.applies_to_library(lib2)); + assert!(!model.applies_to_library(lib3)); // Not in the list + } +} diff --git a/src/db/entities/prelude.rs b/src/db/entities/prelude.rs index 8f7f17b0..111c533e 100644 --- a/src/db/entities/prelude.rs +++ b/src/db/entities/prelude.rs @@ -10,6 +10,12 @@ pub use super::task_metrics::Entity as TaskMetrics; pub use super::tasks::Entity as Tasks; pub use super::users::Entity as Users; +// Plugin entities (exported for external use, may not be used internally) +#[allow(unused_imports)] +pub use super::plugin_failures::Entity as PluginFailures; +#[allow(unused_imports)] +pub use super::plugins::Entity as Plugins; + // Series metadata enhancement entities pub use super::series_metadata::Entity as SeriesMetadata; diff --git a/src/db/entities/series_metadata.rs b/src/db/entities/series_metadata.rs index c828f6f5..a4993b4e 100644 --- a/src/db/entities/series_metadata.rs +++ b/src/db/entities/series_metadata.rs @@ -6,8 +6,80 @@ use chrono::{DateTime, Utc}; use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; +use std::fmt; +use std::str::FromStr; use uuid::Uuid; +// ============================================================================= +// Series Status Enum +// ============================================================================= + +/// Series publication status - canonical values stored in database +/// +/// This enum defines the allowed status values for series metadata. +/// The database stores these as lowercase strings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SeriesStatus { + /// Series is currently being published + Ongoing, + /// Series has finished publication + Ended, + /// Series is on hiatus + Hiatus, + /// Series was abandoned/cancelled + Abandoned, + /// Publication status is unknown + #[default] + Unknown, +} + +impl SeriesStatus { + /// Get the string representation used in the database + pub fn as_str(&self) -> &'static str { + match self { + SeriesStatus::Ongoing => "ongoing", + SeriesStatus::Ended => "ended", + SeriesStatus::Hiatus => "hiatus", + SeriesStatus::Abandoned => "abandoned", + SeriesStatus::Unknown => "unknown", + } + } + + /// All valid status values + #[allow(dead_code)] + pub fn all() -> &'static [SeriesStatus] { + &[ + SeriesStatus::Ongoing, + SeriesStatus::Ended, + SeriesStatus::Hiatus, + SeriesStatus::Abandoned, + SeriesStatus::Unknown, + ] + } +} + +impl fmt::Display for SeriesStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl FromStr for SeriesStatus { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "ongoing" => Ok(SeriesStatus::Ongoing), + "ended" | "completed" => Ok(SeriesStatus::Ended), // Accept "completed" as alias + "hiatus" => Ok(SeriesStatus::Hiatus), + "abandoned" | "cancelled" => Ok(SeriesStatus::Abandoned), // Accept "cancelled" as alias + "unknown" => Ok(SeriesStatus::Unknown), + _ => Err(format!("Invalid series status: {}", s)), + } + } +} + #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] #[sea_orm(table_name = "series_metadata")] pub struct Model { diff --git a/src/db/repositories/book.rs b/src/db/repositories/book.rs index ee4f5edd..dc0609b9 100644 --- a/src/db/repositories/book.rs +++ b/src/db/repositories/book.rs @@ -1514,46 +1514,6 @@ impl BookRepository { Ok(()) } - /// List books with analysis errors - /// Optional filters by library_id or series_id - pub async fn list_with_errors( - db: &DatabaseConnection, - library_id: Option, - series_id: Option, - offset: u64, - page_size: u64, - ) -> Result<(Vec, u64)> { - let mut query = Books::find() - .filter(books::Column::AnalysisError.is_not_null()) - .filter(books::Column::Deleted.eq(false)); - - if let Some(lib_id) = library_id { - query = query.filter(books::Column::LibraryId.eq(lib_id)); - } - - if let Some(ser_id) = series_id { - query = query.filter(books::Column::SeriesId.eq(ser_id)); - } - - // Get total count - let total = query - .clone() - .count(db) - .await - .context("Failed to count books with errors")?; - - // Get paginated results - let books = query - .order_by_desc(books::Column::UpdatedAt) - .offset(offset) - .limit(page_size) - .all(db) - .await - .context("Failed to list books with errors")?; - - Ok((books, total)) - } - /// Set a specific error type for a book /// /// This adds or updates a specific error type in the analysis_errors JSON map. @@ -1660,9 +1620,9 @@ impl BookRepository { Ok(parse_analysis_errors(book.analysis_errors.as_deref())) } - /// List books with errors (using the new analysis_errors JSON field) + /// List books with errors (using analysis_errors JSON field) /// Returns books with their parsed errors, filtered optionally by library, series, or error type - pub async fn list_with_errors_v2( + pub async fn list_with_errors( db: &DatabaseConnection, library_id: Option, series_id: Option, @@ -2843,95 +2803,6 @@ mod tests { assert_eq!(retrieved.analysis_error, None); } - #[tokio::test] - async fn test_list_with_errors() { - let (db, _temp_dir) = create_test_db().await; - - let library = LibraryRepository::create( - db.sea_orm_connection(), - "Test Library", - "/test/path", - ScanningStrategy::Default, - ) - .await - .unwrap(); - - let series = - SeriesRepository::create(db.sea_orm_connection(), library.id, "Test Series", None) - .await - .unwrap(); - - // Create a book without error - let book1 = create_book_model(series.id, library.id, "/test/book1.cbz", "book1.cbz"); - BookRepository::create(db.sea_orm_connection(), &book1, None) - .await - .unwrap(); - - // Create a book with error - let mut book2 = create_book_model(series.id, library.id, "/test/book2.cbz", "book2.cbz"); - book2.analysis_error = Some("Failed to parse: invalid archive".to_string()); - BookRepository::create(db.sea_orm_connection(), &book2, None) - .await - .unwrap(); - - // Create another book with error - let mut book3 = create_book_model(series.id, library.id, "/test/book3.cbz", "book3.cbz"); - book3.analysis_error = Some("Unsupported format".to_string()); - BookRepository::create(db.sea_orm_connection(), &book3, None) - .await - .unwrap(); - - // List all books with errors (no filter) - let (books, total) = - BookRepository::list_with_errors(db.sea_orm_connection(), None, None, 0, 10) - .await - .unwrap(); - - assert_eq!(total, 2); - assert_eq!(books.len(), 2); - assert!(books.iter().all(|b| b.analysis_error.is_some())); - - // List with library filter - let (books, total) = BookRepository::list_with_errors( - db.sea_orm_connection(), - Some(library.id), - None, - 0, - 10, - ) - .await - .unwrap(); - - assert_eq!(total, 2); - assert_eq!(books.len(), 2); - - // List with series filter - let (books, total) = - BookRepository::list_with_errors(db.sea_orm_connection(), None, Some(series.id), 0, 10) - .await - .unwrap(); - - assert_eq!(total, 2); - assert_eq!(books.len(), 2); - - // Test pagination - let (books, total) = - BookRepository::list_with_errors(db.sea_orm_connection(), None, None, 0, 1) - .await - .unwrap(); - - assert_eq!(total, 2); - assert_eq!(books.len(), 1); - - let (books, total) = - BookRepository::list_with_errors(db.sea_orm_connection(), None, None, 1, 1) - .await - .unwrap(); - - assert_eq!(total, 2); - assert_eq!(books.len(), 1); - } - #[tokio::test] async fn test_get_existing_ids() { let (db, _temp_dir) = create_test_db().await; @@ -3356,7 +3227,7 @@ mod tests { } #[tokio::test] - async fn test_list_with_errors_v2() { + async fn test_list_with_errors() { let (db, _temp_dir) = create_test_db().await; let library = LibraryRepository::create( @@ -3418,7 +3289,7 @@ mod tests { // List all books with errors let (books, total) = - BookRepository::list_with_errors_v2(db.sea_orm_connection(), None, None, None, 0, 10) + BookRepository::list_with_errors(db.sea_orm_connection(), None, None, None, 0, 10) .await .unwrap(); @@ -3426,7 +3297,7 @@ mod tests { assert_eq!(books.len(), 2); // Filter by error type - Parser - let (books, total) = BookRepository::list_with_errors_v2( + let (books, total) = BookRepository::list_with_errors( db.sea_orm_connection(), None, None, @@ -3441,7 +3312,7 @@ mod tests { assert_eq!(books.len(), 2); // Filter by error type - Thumbnail - let (books, total) = BookRepository::list_with_errors_v2( + let (books, total) = BookRepository::list_with_errors( db.sea_orm_connection(), None, None, @@ -3458,7 +3329,7 @@ mod tests { // Test pagination let (books, total) = - BookRepository::list_with_errors_v2(db.sea_orm_connection(), None, None, None, 0, 1) + BookRepository::list_with_errors(db.sea_orm_connection(), None, None, None, 0, 1) .await .unwrap(); diff --git a/src/db/repositories/mod.rs b/src/db/repositories/mod.rs index 130bf149..7c6f7f92 100644 --- a/src/db/repositories/mod.rs +++ b/src/db/repositories/mod.rs @@ -10,6 +10,8 @@ pub mod library; pub mod metadata; pub mod metrics; pub mod page; +pub mod plugin_failures; +pub mod plugins; pub mod read_progress; pub mod series; pub mod series_covers; @@ -38,6 +40,8 @@ pub use library::{CreateLibraryParams, LibraryRepository}; pub use metadata::BookMetadataRepository; pub use metrics::MetricsRepository; pub use page::PageRepository; +pub use plugin_failures::{FailureContext, PluginFailuresRepository}; +pub use plugins::PluginsRepository; pub use read_progress::ReadProgressRepository; pub use series::SeriesRepository; pub use series_covers::SeriesCoversRepository; diff --git a/src/db/repositories/plugin_failures.rs b/src/db/repositories/plugin_failures.rs new file mode 100644 index 00000000..2605637b --- /dev/null +++ b/src/db/repositories/plugin_failures.rs @@ -0,0 +1,799 @@ +//! Plugin Failures Repository +//! +//! Provides CRUD operations for plugin failure records, enabling time-windowed failure tracking. +//! This allows the system to auto-disable plugins based on failure rate within a time window +//! (e.g., 3 failures in 1 hour) rather than simple consecutive failure counts. +//! +//! ## Key Features +//! +//! - Record individual failure events with error details +//! - Count failures within a configurable time window +//! - Get recent failures for debugging +//! - Cleanup expired failures + +use crate::db::entities::plugin_failures::{self, Entity as PluginFailures}; +use anyhow::Result; +use chrono::{Duration, Utc}; +use sea_orm::*; +use uuid::Uuid; + +/// Context for a plugin failure event +#[derive(Debug, Clone, Default)] +pub struct FailureContext { + /// Error code for categorization + pub error_code: Option, + /// Which method failed + pub method: Option, + /// JSON-RPC request ID if applicable + pub request_id: Option, + /// Additional context (parameters, stack trace, etc.) + pub context: Option, + /// Sanitized summary of the request parameters (sensitive fields redacted) + pub request_summary: Option, +} + +/// Fields that should be redacted from request summaries +#[allow(dead_code)] // Available for callers to use when recording failures +const SENSITIVE_FIELD_PATTERNS: &[&str] = &[ + "key", + "secret", + "token", + "password", + "credential", + "auth", + "bearer", + "api_key", + "apikey", +]; + +/// Redact sensitive fields from a JSON value +/// +/// Any object keys containing the patterns in `SENSITIVE_FIELD_PATTERNS` (case-insensitive) +/// will have their values replaced with "[REDACTED]". +#[allow(dead_code)] // Available for callers to use when recording failures +pub fn redact_sensitive_fields(value: &serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::Object(map) => { + let redacted: serde_json::Map = map + .iter() + .map(|(k, v)| { + let key_lower = k.to_lowercase(); + let is_sensitive = SENSITIVE_FIELD_PATTERNS + .iter() + .any(|pattern| key_lower.contains(pattern)); + + if is_sensitive { + ( + k.clone(), + serde_json::Value::String("[REDACTED]".to_string()), + ) + } else { + (k.clone(), redact_sensitive_fields(v)) + } + }) + .collect(); + serde_json::Value::Object(redacted) + } + serde_json::Value::Array(arr) => { + serde_json::Value::Array(arr.iter().map(redact_sensitive_fields).collect()) + } + // Primitive values pass through unchanged + other => other.clone(), + } +} + +/// Create a redacted summary string from request parameters +/// +/// Converts the value to JSON, redacts sensitive fields, and truncates if necessary. +#[allow(dead_code)] // Available for callers to use when recording failures +pub fn create_request_summary(params: &serde_json::Value, max_length: usize) -> String { + let redacted = redact_sensitive_fields(params); + let json_str = serde_json::to_string(&redacted).unwrap_or_else(|_| "{}".to_string()); + + if json_str.len() > max_length { + format!("{}...", &json_str[..max_length.saturating_sub(3)]) + } else { + json_str + } +} + +pub struct PluginFailuresRepository; + +impl PluginFailuresRepository { + // ========================================================================= + // Create Operations + // ========================================================================= + + /// Record a new failure event for a plugin + /// + /// # Arguments + /// * `db` - Database connection + /// * `plugin_id` - ID of the plugin that failed + /// * `error_message` - Human-readable error message + /// * `failure_context` - Additional context about the failure + /// * `retention_days` - How long to keep the failure record (default: 30 days) + pub async fn record_failure( + db: &DatabaseConnection, + plugin_id: Uuid, + error_message: &str, + failure_context: FailureContext, + retention_days: Option, + ) -> Result { + let now = Utc::now(); + let retention = retention_days.unwrap_or(30); + let expires_at = now + Duration::days(retention); + + let failure = plugin_failures::ActiveModel { + id: Set(Uuid::new_v4()), + plugin_id: Set(plugin_id), + error_message: Set(error_message.to_string()), + error_code: Set(failure_context.error_code), + method: Set(failure_context.method), + request_id: Set(failure_context.request_id), + context: Set(failure_context.context), + request_summary: Set(failure_context.request_summary), + occurred_at: Set(now), + expires_at: Set(expires_at), + }; + + let result = failure.insert(db).await?; + Ok(result) + } + + // ========================================================================= + // Read Operations + // ========================================================================= + + /// Count failures for a plugin within a time window + /// + /// # Arguments + /// * `db` - Database connection + /// * `plugin_id` - ID of the plugin + /// * `window_seconds` - Time window in seconds (e.g., 3600 for 1 hour) + /// + /// # Returns + /// Number of failures within the time window + pub async fn count_failures_in_window( + db: &DatabaseConnection, + plugin_id: Uuid, + window_seconds: i64, + ) -> Result { + let window_start = Utc::now() - Duration::seconds(window_seconds); + + let count = PluginFailures::find() + .filter(plugin_failures::Column::PluginId.eq(plugin_id)) + .filter(plugin_failures::Column::OccurredAt.gte(window_start)) + .count(db) + .await?; + + Ok(count) + } + + /// Get recent failures for a plugin + /// + /// # Arguments + /// * `db` - Database connection + /// * `plugin_id` - ID of the plugin + /// * `limit` - Maximum number of failures to return + /// + /// # Returns + /// List of recent failures, ordered by most recent first + #[allow(dead_code)] // Available for future use + pub async fn get_recent_failures( + db: &DatabaseConnection, + plugin_id: Uuid, + limit: u64, + ) -> Result> { + let failures = PluginFailures::find() + .filter(plugin_failures::Column::PluginId.eq(plugin_id)) + .order_by_desc(plugin_failures::Column::OccurredAt) + .limit(limit) + .all(db) + .await?; + + Ok(failures) + } + + /// Get all failures for a plugin with pagination + /// + /// # Arguments + /// * `db` - Database connection + /// * `plugin_id` - ID of the plugin + /// * `limit` - Maximum number of failures to return + /// * `offset` - Number of failures to skip + /// + /// # Returns + /// Tuple of (failures, total count) + pub async fn get_failures_paginated( + db: &DatabaseConnection, + plugin_id: Uuid, + limit: u64, + offset: u64, + ) -> Result<(Vec, u64)> { + let total = PluginFailures::find() + .filter(plugin_failures::Column::PluginId.eq(plugin_id)) + .count(db) + .await?; + + let failures = PluginFailures::find() + .filter(plugin_failures::Column::PluginId.eq(plugin_id)) + .order_by_desc(plugin_failures::Column::OccurredAt) + .limit(limit) + .offset(offset) + .all(db) + .await?; + + Ok((failures, total)) + } + + /// Get a single failure by ID + #[allow(dead_code)] // Available for future use + pub async fn get_by_id( + db: &DatabaseConnection, + id: Uuid, + ) -> Result> { + let failure = PluginFailures::find_by_id(id).one(db).await?; + Ok(failure) + } + + // ========================================================================= + // Delete Operations + // ========================================================================= + + /// Clean up expired failures + /// + /// # Returns + /// Number of failures deleted + #[allow(dead_code)] // Called by scheduled cleanup task + pub async fn cleanup_expired(db: &DatabaseConnection) -> Result { + let now = Utc::now(); + + let result = PluginFailures::delete_many() + .filter(plugin_failures::Column::ExpiresAt.lt(now)) + .exec(db) + .await?; + + Ok(result.rows_affected) + } + + /// Delete all failures for a plugin + /// + /// # Returns + /// Number of failures deleted + #[allow(dead_code)] // Available for future use + pub async fn delete_all_for_plugin(db: &DatabaseConnection, plugin_id: Uuid) -> Result { + let result = PluginFailures::delete_many() + .filter(plugin_failures::Column::PluginId.eq(plugin_id)) + .exec(db) + .await?; + + Ok(result.rows_affected) + } + + /// Delete failures older than a specific timestamp for a plugin + #[allow(dead_code)] // Available for future use + pub async fn delete_older_than( + db: &DatabaseConnection, + plugin_id: Uuid, + before: chrono::DateTime, + ) -> Result { + let result = PluginFailures::delete_many() + .filter(plugin_failures::Column::PluginId.eq(plugin_id)) + .filter(plugin_failures::Column::OccurredAt.lt(before)) + .exec(db) + .await?; + + Ok(result.rows_affected) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::entities::plugin_failures::error_codes; + use crate::db::repositories::PluginsRepository; + use crate::db::test_helpers::setup_test_db; + use crate::services::plugin::protocol::PluginScope; + use std::env; + use tokio::time::sleep; + + fn setup_test_encryption_key() { + if env::var("CODEX_ENCRYPTION_KEY").is_err() { + env::set_var( + "CODEX_ENCRYPTION_KEY", + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + ); + } + } + + async fn create_test_plugin(db: &DatabaseConnection) -> Uuid { + setup_test_encryption_key(); + let plugin = PluginsRepository::create( + db, + &format!("test_{}", Uuid::new_v4()), + "Test Plugin", + None, + "system", + "node", + vec![], + vec![], + None, + vec![], + vec![PluginScope::SeriesDetail], + vec![], // library_ids - empty means all libraries + None, + "env", + None, + true, + None, + Some(60), // rate_limit_requests_per_minute + ) + .await + .unwrap(); + plugin.id + } + + #[tokio::test] + async fn test_record_failure() { + let db = setup_test_db().await; + let plugin_id = create_test_plugin(&db).await; + + let params = serde_json::json!({"query": "one piece"}); + let failure = PluginFailuresRepository::record_failure( + &db, + plugin_id, + "Connection timeout after 30s", + FailureContext { + error_code: Some(error_codes::TIMEOUT.to_string()), + method: Some("metadata/search".to_string()), + request_id: Some("req-123".to_string()), + context: Some(params.clone()), + request_summary: Some(create_request_summary(¶ms, 1000)), + }, + None, + ) + .await + .unwrap(); + + assert_eq!(failure.plugin_id, plugin_id); + assert_eq!(failure.error_message, "Connection timeout after 30s"); + assert_eq!(failure.error_code, Some(error_codes::TIMEOUT.to_string())); + assert_eq!(failure.method, Some("metadata/search".to_string())); + assert_eq!(failure.request_id, Some("req-123".to_string())); + assert!(failure.context.is_some()); + assert!(failure.request_summary.is_some()); + } + + #[tokio::test] + async fn test_count_failures_in_window() { + let db = setup_test_db().await; + let plugin_id = create_test_plugin(&db).await; + + // Record 3 failures + for i in 0..3 { + PluginFailuresRepository::record_failure( + &db, + plugin_id, + &format!("Error {}", i), + FailureContext::default(), + None, + ) + .await + .unwrap(); + } + + // Count failures in 1 hour window + let count = PluginFailuresRepository::count_failures_in_window(&db, plugin_id, 3600) + .await + .unwrap(); + assert_eq!(count, 3); + + // Count with very short window (should be 0 after a small delay) + // Since all failures just occurred, they should still be within even a 1-second window + let count = PluginFailuresRepository::count_failures_in_window(&db, plugin_id, 1) + .await + .unwrap(); + assert_eq!(count, 3); // All failures are very recent + } + + #[tokio::test] + async fn test_get_recent_failures() { + let db = setup_test_db().await; + let plugin_id = create_test_plugin(&db).await; + + // Record 5 failures with small delays + for i in 0..5 { + PluginFailuresRepository::record_failure( + &db, + plugin_id, + &format!("Error {}", i), + FailureContext { + error_code: Some(format!("CODE_{}", i)), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + // Small delay to ensure ordering + sleep(std::time::Duration::from_millis(10)).await; + } + + // Get last 3 failures + let failures = PluginFailuresRepository::get_recent_failures(&db, plugin_id, 3) + .await + .unwrap(); + + assert_eq!(failures.len(), 3); + // Should be in descending order (most recent first) + assert_eq!(failures[0].error_message, "Error 4"); + assert_eq!(failures[1].error_message, "Error 3"); + assert_eq!(failures[2].error_message, "Error 2"); + } + + #[tokio::test] + async fn test_get_failures_paginated() { + let db = setup_test_db().await; + let plugin_id = create_test_plugin(&db).await; + + // Record 5 failures + for i in 0..5 { + PluginFailuresRepository::record_failure( + &db, + plugin_id, + &format!("Error {}", i), + FailureContext::default(), + None, + ) + .await + .unwrap(); + } + + // Get page 1 (first 2) + let (failures, total) = + PluginFailuresRepository::get_failures_paginated(&db, plugin_id, 2, 0) + .await + .unwrap(); + + assert_eq!(total, 5); + assert_eq!(failures.len(), 2); + + // Get page 2 (next 2) + let (failures, _) = PluginFailuresRepository::get_failures_paginated(&db, plugin_id, 2, 2) + .await + .unwrap(); + + assert_eq!(failures.len(), 2); + } + + #[tokio::test] + async fn test_delete_all_for_plugin() { + let db = setup_test_db().await; + let plugin_id = create_test_plugin(&db).await; + let other_plugin_id = create_test_plugin(&db).await; + + // Record failures for both plugins + for i in 0..3 { + PluginFailuresRepository::record_failure( + &db, + plugin_id, + &format!("Error {}", i), + FailureContext::default(), + None, + ) + .await + .unwrap(); + } + + PluginFailuresRepository::record_failure( + &db, + other_plugin_id, + "Other plugin error", + FailureContext::default(), + None, + ) + .await + .unwrap(); + + // Delete all for first plugin + let deleted = PluginFailuresRepository::delete_all_for_plugin(&db, plugin_id) + .await + .unwrap(); + + assert_eq!(deleted, 3); + + // Verify first plugin has no failures + let count = PluginFailuresRepository::count_failures_in_window(&db, plugin_id, 3600) + .await + .unwrap(); + assert_eq!(count, 0); + + // Verify other plugin still has failures + let count = PluginFailuresRepository::count_failures_in_window(&db, other_plugin_id, 3600) + .await + .unwrap(); + assert_eq!(count, 1); + } + + #[tokio::test] + async fn test_cleanup_expired() { + let db = setup_test_db().await; + let plugin_id = create_test_plugin(&db).await; + + // Record a failure with very short retention (essentially already expired) + // We'll use -1 days to make it expire immediately + let failure = PluginFailuresRepository::record_failure( + &db, + plugin_id, + "Expired error", + FailureContext::default(), + Some(-1), // Already expired + ) + .await + .unwrap(); + + // Verify failure exists + let found = PluginFailuresRepository::get_by_id(&db, failure.id) + .await + .unwrap(); + assert!(found.is_some()); + + // Cleanup expired + let deleted = PluginFailuresRepository::cleanup_expired(&db) + .await + .unwrap(); + assert_eq!(deleted, 1); + + // Verify failure is gone + let found = PluginFailuresRepository::get_by_id(&db, failure.id) + .await + .unwrap(); + assert!(found.is_none()); + } + + #[tokio::test] + async fn test_custom_retention_period() { + let db = setup_test_db().await; + let plugin_id = create_test_plugin(&db).await; + + // Record with custom 7-day retention + let failure = PluginFailuresRepository::record_failure( + &db, + plugin_id, + "Short retention error", + FailureContext::default(), + Some(7), + ) + .await + .unwrap(); + + // Verify expires_at is approximately 7 days from now + let now = Utc::now(); + let expected_expiry = now + Duration::days(7); + let diff = (failure.expires_at - expected_expiry).num_seconds().abs(); + + // Allow 5 seconds of tolerance + assert!(diff < 5, "Expiry time should be ~7 days from now"); + } + + #[tokio::test] + async fn test_failures_isolated_by_plugin() { + let db = setup_test_db().await; + let plugin_a = create_test_plugin(&db).await; + let plugin_b = create_test_plugin(&db).await; + + // Record failures for plugin A + for _ in 0..5 { + PluginFailuresRepository::record_failure( + &db, + plugin_a, + "Plugin A error", + FailureContext::default(), + None, + ) + .await + .unwrap(); + } + + // Record failures for plugin B + for _ in 0..2 { + PluginFailuresRepository::record_failure( + &db, + plugin_b, + "Plugin B error", + FailureContext::default(), + None, + ) + .await + .unwrap(); + } + + // Verify counts are isolated + let count_a = PluginFailuresRepository::count_failures_in_window(&db, plugin_a, 3600) + .await + .unwrap(); + let count_b = PluginFailuresRepository::count_failures_in_window(&db, plugin_b, 3600) + .await + .unwrap(); + + assert_eq!(count_a, 5); + assert_eq!(count_b, 2); + } + + // ========================================================================= + // Redaction Tests + // ========================================================================= + + #[test] + fn test_redact_sensitive_fields_simple() { + let input = serde_json::json!({ + "query": "one piece", + "api_key": "secret-123", + "page": 1 + }); + + let redacted = redact_sensitive_fields(&input); + + assert_eq!(redacted["query"], "one piece"); + assert_eq!(redacted["api_key"], "[REDACTED]"); + assert_eq!(redacted["page"], 1); + } + + #[test] + fn test_redact_sensitive_fields_nested() { + let input = serde_json::json!({ + "auth": { + "token": "bearer-xyz", + "username": "admin" + }, + "data": { + "password": "secret", + "value": 42 + } + }); + + let redacted = redact_sensitive_fields(&input); + + // Top-level "auth" key should be redacted entirely + assert_eq!(redacted["auth"], "[REDACTED]"); + // Nested "password" should be redacted, but "value" preserved + assert_eq!(redacted["data"]["password"], "[REDACTED]"); + assert_eq!(redacted["data"]["value"], 42); + } + + #[test] + fn test_redact_sensitive_fields_array() { + let input = serde_json::json!({ + "items": [ + {"name": "item1", "secret_key": "abc"}, + {"name": "item2", "secret_key": "xyz"} + ] + }); + + let redacted = redact_sensitive_fields(&input); + + let items = redacted["items"].as_array().unwrap(); + assert_eq!(items[0]["name"], "item1"); + assert_eq!(items[0]["secret_key"], "[REDACTED]"); + assert_eq!(items[1]["name"], "item2"); + assert_eq!(items[1]["secret_key"], "[REDACTED]"); + } + + #[test] + fn test_redact_sensitive_fields_case_insensitive() { + let input = serde_json::json!({ + "API_KEY": "key1", + "ApiKey": "key2", + "apikey": "key3", + "TOKEN": "tok1", + "Bearer_Token": "tok2" + }); + + let redacted = redact_sensitive_fields(&input); + + assert_eq!(redacted["API_KEY"], "[REDACTED]"); + assert_eq!(redacted["ApiKey"], "[REDACTED]"); + assert_eq!(redacted["apikey"], "[REDACTED]"); + assert_eq!(redacted["TOKEN"], "[REDACTED]"); + assert_eq!(redacted["Bearer_Token"], "[REDACTED]"); + } + + #[test] + fn test_redact_sensitive_fields_preserves_non_sensitive() { + let input = serde_json::json!({ + "query": "search term", + "limit": 10, + "offset": 0, + "series_id": "12345", + "include_covers": true, + "tags": ["action", "adventure"] + }); + + let redacted = redact_sensitive_fields(&input); + + assert_eq!(redacted["query"], "search term"); + assert_eq!(redacted["limit"], 10); + assert_eq!(redacted["offset"], 0); + assert_eq!(redacted["series_id"], "12345"); + assert_eq!(redacted["include_covers"], true); + assert_eq!(redacted["tags"][0], "action"); + } + + #[test] + fn test_create_request_summary_basic() { + let input = serde_json::json!({ + "query": "one piece", + "page": 1 + }); + + let summary = create_request_summary(&input, 1000); + + // Should be valid JSON containing the fields + assert!(summary.contains("query")); + assert!(summary.contains("one piece")); + assert!(summary.contains("page")); + } + + #[test] + fn test_create_request_summary_truncates() { + let input = serde_json::json!({ + "description": "A very long description that should be truncated when the max length is small" + }); + + let summary = create_request_summary(&input, 30); + + assert!(summary.len() <= 30); + assert!(summary.ends_with("...")); + } + + #[test] + fn test_create_request_summary_redacts_and_truncates() { + let input = serde_json::json!({ + "query": "search", + "api_key": "super-secret-key-that-is-very-long" + }); + + let summary = create_request_summary(&input, 100); + + // Should contain redacted version + assert!(summary.contains("[REDACTED]")); + // Should NOT contain the actual secret + assert!(!summary.contains("super-secret-key")); + } + + #[tokio::test] + async fn test_record_failure_with_request_summary() { + let db = setup_test_db().await; + let plugin_id = create_test_plugin(&db).await; + + let params = serde_json::json!({ + "query": "one piece", + "api_key": "secret-123" + }); + + let failure = PluginFailuresRepository::record_failure( + &db, + plugin_id, + "API error", + FailureContext { + error_code: Some("API_ERROR".to_string()), + method: Some("metadata/search".to_string()), + request_id: None, + context: None, + request_summary: Some(create_request_summary(¶ms, 1000)), + }, + None, + ) + .await + .unwrap(); + + // Verify request_summary is stored + assert!(failure.request_summary.is_some()); + let summary = failure.request_summary.unwrap(); + + // Should contain redacted value, not the secret + assert!(summary.contains("[REDACTED]")); + assert!(!summary.contains("secret-123")); + assert!(summary.contains("one piece")); + } +} diff --git a/src/db/repositories/plugins.rs b/src/db/repositories/plugins.rs new file mode 100644 index 00000000..8b321373 --- /dev/null +++ b/src/db/repositories/plugins.rs @@ -0,0 +1,1513 @@ +//! Plugins Repository +//! +//! Provides CRUD operations for external metadata provider plugins. +//! Credentials are encrypted at rest using AES-256-GCM. +//! +//! ## Key Features +//! +//! - Create, read, update, delete plugin configurations +//! - Encrypted credential storage +//! - Health status tracking with failure counting +//! - Filter by scope, enabled status, health status +//! +//! TODO: Remove allow(dead_code) once plugin features are fully implemented + +#![allow(dead_code)] + +use crate::db::entities::plugins::{self, Entity as Plugins, PluginPermission}; +use crate::services::plugin::protocol::PluginScope; +use crate::services::CredentialEncryption; +use anyhow::{anyhow, Result}; +use chrono::Utc; +use sea_orm::*; +use uuid::Uuid; + +pub struct PluginsRepository; + +impl PluginsRepository { + // ========================================================================= + // Read Operations + // ========================================================================= + + /// Get all plugins + pub async fn get_all(db: &DatabaseConnection) -> Result> { + let plugins = Plugins::find() + .order_by_asc(plugins::Column::Name) + .all(db) + .await?; + Ok(plugins) + } + + /// Get all plugins with pagination + /// + /// Returns a tuple of (plugins, total_count) for building paginated responses. + /// Use limit=0 to get total count without loading any plugins. + pub async fn get_all_paginated( + db: &DatabaseConnection, + limit: u64, + offset: u64, + ) -> Result<(Vec, u64)> { + let total = Plugins::find().count(db).await?; + + if limit == 0 { + return Ok((vec![], total)); + } + + let plugins = Plugins::find() + .order_by_asc(plugins::Column::Name) + .limit(limit) + .offset(offset) + .all(db) + .await?; + Ok((plugins, total)) + } + + /// Get all enabled plugins + pub async fn get_enabled(db: &DatabaseConnection) -> Result> { + let plugins = Plugins::find() + .filter(plugins::Column::Enabled.eq(true)) + .order_by_asc(plugins::Column::Name) + .all(db) + .await?; + Ok(plugins) + } + + /// Get all enabled plugins with pagination + /// + /// Returns a tuple of (plugins, total_count) for building paginated responses. + /// Use limit=0 to get total count without loading any plugins. + pub async fn get_enabled_paginated( + db: &DatabaseConnection, + limit: u64, + offset: u64, + ) -> Result<(Vec, u64)> { + let total = Plugins::find() + .filter(plugins::Column::Enabled.eq(true)) + .count(db) + .await?; + + if limit == 0 { + return Ok((vec![], total)); + } + + let plugins = Plugins::find() + .filter(plugins::Column::Enabled.eq(true)) + .order_by_asc(plugins::Column::Name) + .limit(limit) + .offset(offset) + .all(db) + .await?; + Ok((plugins, total)) + } + + /// Get a plugin by ID + pub async fn get_by_id(db: &DatabaseConnection, id: Uuid) -> Result> { + let plugin = Plugins::find_by_id(id).one(db).await?; + Ok(plugin) + } + + /// Get a plugin by name + pub async fn get_by_name( + db: &DatabaseConnection, + name: &str, + ) -> Result> { + let plugin = Plugins::find() + .filter(plugins::Column::Name.eq(name)) + .one(db) + .await?; + Ok(plugin) + } + + /// Get enabled plugins that support a specific scope + /// + /// Note: This performs in-memory filtering since JSON array queries vary by database. + /// For small plugin counts (typical), this is efficient enough. + pub async fn get_enabled_by_scope( + db: &DatabaseConnection, + scope: &PluginScope, + ) -> Result> { + let enabled = Self::get_enabled(db).await?; + let filtered = enabled.into_iter().filter(|p| p.has_scope(scope)).collect(); + Ok(filtered) + } + + /// Get enabled plugins that support a specific scope AND apply to a specific library + /// + /// This filters plugins by: + /// 1. Enabled status + /// 2. Scope support + /// 3. Library filtering (empty library_ids = all libraries, or library must be in the list) + pub async fn get_enabled_by_scope_and_library( + db: &DatabaseConnection, + scope: &PluginScope, + library_id: Uuid, + ) -> Result> { + let enabled = Self::get_enabled(db).await?; + let filtered = enabled + .into_iter() + .filter(|p| p.has_scope(scope) && p.applies_to_library(library_id)) + .collect(); + Ok(filtered) + } + + /// Get plugins by health status + pub async fn get_by_health_status( + db: &DatabaseConnection, + status: &str, + ) -> Result> { + let plugins = Plugins::find() + .filter(plugins::Column::HealthStatus.eq(status)) + .order_by_asc(plugins::Column::Name) + .all(db) + .await?; + Ok(plugins) + } + + /// Get plugins that are disabled due to failures (auto-disabled) + pub async fn get_auto_disabled(db: &DatabaseConnection) -> Result> { + let plugins = Plugins::find() + .filter(plugins::Column::Enabled.eq(false)) + .filter(plugins::Column::DisabledReason.is_not_null()) + .order_by_desc(plugins::Column::LastFailureAt) + .all(db) + .await?; + Ok(plugins) + } + + /// Get plugins by type (system or user) + pub async fn get_by_type( + db: &DatabaseConnection, + plugin_type: &str, + ) -> Result> { + let plugins = Plugins::find() + .filter(plugins::Column::PluginType.eq(plugin_type)) + .order_by_asc(plugins::Column::Name) + .all(db) + .await?; + Ok(plugins) + } + + /// Get enabled plugins by type + pub async fn get_enabled_by_type( + db: &DatabaseConnection, + plugin_type: &str, + ) -> Result> { + let plugins = Plugins::find() + .filter(plugins::Column::PluginType.eq(plugin_type)) + .filter(plugins::Column::Enabled.eq(true)) + .order_by_asc(plugins::Column::Name) + .all(db) + .await?; + Ok(plugins) + } + + /// Get all system plugins (admin-configured) + pub async fn get_system_plugins(db: &DatabaseConnection) -> Result> { + Self::get_by_type(db, "system").await + } + + /// Get all user plugins (per-user instances) + pub async fn get_user_plugins(db: &DatabaseConnection) -> Result> { + Self::get_by_type(db, "user").await + } + + // ========================================================================= + // Create Operations + // ========================================================================= + + /// Create a new plugin + #[allow(clippy::too_many_arguments)] + pub async fn create( + db: &DatabaseConnection, + name: &str, + display_name: &str, + description: Option<&str>, + plugin_type: &str, + command: &str, + args: Vec, + env: Vec<(String, String)>, + working_directory: Option<&str>, + permissions: Vec, + scopes: Vec, + library_ids: Vec, + credentials: Option<&serde_json::Value>, + credential_delivery: &str, + config: Option, + enabled: bool, + created_by: Option, + rate_limit_requests_per_minute: Option, + ) -> Result { + let now = Utc::now(); + + // Encrypt credentials if provided + let encrypted_credentials = if let Some(creds) = credentials { + let encryption = CredentialEncryption::global()?; + Some(encryption.encrypt_json(creds)?) + } else { + None + }; + + // Convert permissions, scopes, and library_ids to JSON + let permissions_json = serde_json::to_value(&permissions)?; + let scopes_json = serde_json::to_value(&scopes)?; + let library_ids_json: serde_json::Value = library_ids + .iter() + .map(|id| serde_json::Value::String(id.to_string())) + .collect(); + let args_json = serde_json::to_value(&args)?; + let env_json: serde_json::Value = env + .into_iter() + .map(|(k, v)| (k, serde_json::Value::String(v))) + .collect::>() + .into(); + + let plugin = plugins::ActiveModel { + id: Set(Uuid::new_v4()), + name: Set(name.to_string()), + display_name: Set(display_name.to_string()), + description: Set(description.map(|s| s.to_string())), + plugin_type: Set(plugin_type.to_string()), + command: Set(command.to_string()), + args: Set(args_json), + env: Set(env_json), + working_directory: Set(working_directory.map(|s| s.to_string())), + permissions: Set(permissions_json), + scopes: Set(scopes_json), + library_ids: Set(library_ids_json), + credentials: Set(encrypted_credentials), + credential_delivery: Set(credential_delivery.to_string()), + config: Set(config.unwrap_or(serde_json::json!({}))), + manifest: Set(None), + enabled: Set(enabled), + health_status: Set("unknown".to_string()), + failure_count: Set(0), + last_failure_at: Set(None), + last_success_at: Set(None), + disabled_reason: Set(None), + rate_limit_requests_per_minute: Set(rate_limit_requests_per_minute), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(created_by), + updated_by: Set(created_by), + }; + + let result = plugin.insert(db).await?; + Ok(result) + } + + // ========================================================================= + // Update Operations + // ========================================================================= + + /// Update a plugin's basic information + #[allow(clippy::too_many_arguments)] + pub async fn update( + db: &DatabaseConnection, + id: Uuid, + display_name: Option, + description: Option>, + command: Option, + args: Option>, + env: Option>, + working_directory: Option>, + permissions: Option>, + scopes: Option>, + library_ids: Option>, + credential_delivery: Option, + config: Option, + updated_by: Option, + rate_limit_requests_per_minute: Option>, + ) -> Result { + let existing = Self::get_by_id(db, id) + .await? + .ok_or_else(|| anyhow!("Plugin not found: {}", id))?; + + let mut active_model: plugins::ActiveModel = existing.into(); + active_model.updated_at = Set(Utc::now()); + active_model.updated_by = Set(updated_by); + + if let Some(name) = display_name { + active_model.display_name = Set(name); + } + + if let Some(desc) = description { + active_model.description = Set(desc); + } + + if let Some(cmd) = command { + active_model.command = Set(cmd); + } + + if let Some(a) = args { + active_model.args = Set(serde_json::to_value(&a)?); + } + + if let Some(e) = env { + let env_json: serde_json::Value = e + .into_iter() + .map(|(k, v)| (k, serde_json::Value::String(v))) + .collect::>() + .into(); + active_model.env = Set(env_json); + } + + if let Some(wd) = working_directory { + active_model.working_directory = Set(wd); + } + + if let Some(perms) = permissions { + active_model.permissions = Set(serde_json::to_value(&perms)?); + } + + if let Some(s) = scopes { + active_model.scopes = Set(serde_json::to_value(&s)?); + } + + if let Some(lib_ids) = library_ids { + let library_ids_json: serde_json::Value = lib_ids + .iter() + .map(|id| serde_json::Value::String(id.to_string())) + .collect(); + active_model.library_ids = Set(library_ids_json); + } + + if let Some(delivery) = credential_delivery { + active_model.credential_delivery = Set(delivery); + } + + if let Some(cfg) = config { + active_model.config = Set(cfg); + } + + if let Some(rate_limit) = rate_limit_requests_per_minute { + active_model.rate_limit_requests_per_minute = Set(rate_limit); + } + + let result = active_model.update(db).await?; + Ok(result) + } + + /// Update plugin credentials + pub async fn update_credentials( + db: &DatabaseConnection, + id: Uuid, + credentials: Option<&serde_json::Value>, + updated_by: Option, + ) -> Result { + let existing = Self::get_by_id(db, id) + .await? + .ok_or_else(|| anyhow!("Plugin not found: {}", id))?; + + let mut active_model: plugins::ActiveModel = existing.into(); + active_model.updated_at = Set(Utc::now()); + active_model.updated_by = Set(updated_by); + + let encrypted = if let Some(creds) = credentials { + let encryption = CredentialEncryption::global()?; + Some(encryption.encrypt_json(creds)?) + } else { + None + }; + active_model.credentials = Set(encrypted); + + let result = active_model.update(db).await?; + Ok(result) + } + + /// Update cached manifest from plugin + pub async fn update_manifest( + db: &DatabaseConnection, + id: Uuid, + manifest: Option, + ) -> Result { + let existing = Self::get_by_id(db, id) + .await? + .ok_or_else(|| anyhow!("Plugin not found: {}", id))?; + + let mut active_model: plugins::ActiveModel = existing.into(); + active_model.manifest = Set(manifest); + active_model.updated_at = Set(Utc::now()); + + let result = active_model.update(db).await?; + Ok(result) + } + + /// Enable a plugin + pub async fn enable( + db: &DatabaseConnection, + id: Uuid, + updated_by: Option, + ) -> Result { + let existing = Self::get_by_id(db, id) + .await? + .ok_or_else(|| anyhow!("Plugin not found: {}", id))?; + + let mut active_model: plugins::ActiveModel = existing.into(); + active_model.enabled = Set(true); + active_model.updated_at = Set(Utc::now()); + active_model.updated_by = Set(updated_by); + // Reset health status when enabling + active_model.health_status = Set("unknown".to_string()); + // Clear disabled reason + active_model.disabled_reason = Set(None); + + let result = active_model.update(db).await?; + Ok(result) + } + + /// Disable a plugin (manual disable by admin) + pub async fn disable( + db: &DatabaseConnection, + id: Uuid, + updated_by: Option, + ) -> Result { + let existing = Self::get_by_id(db, id) + .await? + .ok_or_else(|| anyhow!("Plugin not found: {}", id))?; + + let mut active_model: plugins::ActiveModel = existing.into(); + active_model.enabled = Set(false); + active_model.updated_at = Set(Utc::now()); + active_model.updated_by = Set(updated_by); + active_model.health_status = Set("disabled".to_string()); + + let result = active_model.update(db).await?; + Ok(result) + } + + /// Auto-disable a plugin due to repeated failures + pub async fn auto_disable( + db: &DatabaseConnection, + id: Uuid, + reason: &str, + ) -> Result { + let existing = Self::get_by_id(db, id) + .await? + .ok_or_else(|| anyhow!("Plugin not found: {}", id))?; + + let mut active_model: plugins::ActiveModel = existing.into(); + active_model.enabled = Set(false); + active_model.updated_at = Set(Utc::now()); + active_model.health_status = Set("disabled".to_string()); + active_model.disabled_reason = Set(Some(reason.to_string())); + + let result = active_model.update(db).await?; + Ok(result) + } + + // ========================================================================= + // Health Status Operations + // ========================================================================= + + /// Record a successful operation + pub async fn record_success(db: &DatabaseConnection, id: Uuid) -> Result { + let existing = Self::get_by_id(db, id) + .await? + .ok_or_else(|| anyhow!("Plugin not found: {}", id))?; + + let mut active_model: plugins::ActiveModel = existing.into(); + active_model.health_status = Set("healthy".to_string()); + active_model.failure_count = Set(0); + active_model.last_success_at = Set(Some(Utc::now())); + active_model.updated_at = Set(Utc::now()); + + let result = active_model.update(db).await?; + Ok(result) + } + + /// Record a failed operation and increment failure count + pub async fn record_failure( + db: &DatabaseConnection, + id: Uuid, + error_message: Option<&str>, + ) -> Result { + let existing = Self::get_by_id(db, id) + .await? + .ok_or_else(|| anyhow!("Plugin not found: {}", id))?; + + let new_failure_count = existing.failure_count + 1; + + let mut active_model: plugins::ActiveModel = existing.into(); + active_model.health_status = Set("unhealthy".to_string()); + active_model.failure_count = Set(new_failure_count); + active_model.last_failure_at = Set(Some(Utc::now())); + active_model.disabled_reason = Set(error_message.map(|s| s.to_string())); + active_model.updated_at = Set(Utc::now()); + + let result = active_model.update(db).await?; + Ok(result) + } + + /// Reset failure count (e.g., after manual re-enable) + pub async fn reset_failure_count( + db: &DatabaseConnection, + id: Uuid, + updated_by: Option, + ) -> Result { + let existing = Self::get_by_id(db, id) + .await? + .ok_or_else(|| anyhow!("Plugin not found: {}", id))?; + + let mut active_model: plugins::ActiveModel = existing.into(); + active_model.failure_count = Set(0); + active_model.health_status = Set("unknown".to_string()); + active_model.disabled_reason = Set(None); + active_model.updated_at = Set(Utc::now()); + active_model.updated_by = Set(updated_by); + + let result = active_model.update(db).await?; + Ok(result) + } + + // ========================================================================= + // Delete Operations + // ========================================================================= + + /// Delete a plugin + pub async fn delete(db: &DatabaseConnection, id: Uuid) -> Result { + let result = Plugins::delete_by_id(id).exec(db).await?; + Ok(result.rows_affected > 0) + } + + // ========================================================================= + // Credential Operations + // ========================================================================= + + /// Get decrypted credentials for a plugin + pub async fn get_credentials( + db: &DatabaseConnection, + id: Uuid, + ) -> Result> { + let plugin = Self::get_by_id(db, id) + .await? + .ok_or_else(|| anyhow!("Plugin not found: {}", id))?; + + if let Some(encrypted) = plugin.credentials { + let encryption = CredentialEncryption::global()?; + let decrypted: serde_json::Value = encryption.decrypt_json(&encrypted)?; + Ok(Some(decrypted)) + } else { + Ok(None) + } + } + + /// Check if a plugin has credentials set + pub fn has_credentials(plugin: &plugins::Model) -> bool { + plugin.credentials.is_some() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::test_helpers::setup_test_db; + use std::env; + + fn setup_test_encryption_key() { + // Set a test encryption key if not already set + if env::var("CODEX_ENCRYPTION_KEY").is_err() { + env::set_var( + "CODEX_ENCRYPTION_KEY", + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + ); + } + } + + #[tokio::test] + async fn test_create_plugin_basic() { + setup_test_encryption_key(); + let db = setup_test_db().await; + + let plugin = PluginsRepository::create( + &db, + "test_plugin", + "Test Plugin", + Some("A test plugin"), + "system", + "node", + vec!["dist/index.js".to_string()], + vec![], + None, + vec![PluginPermission::MetadataWriteSummary], + vec![PluginScope::SeriesDetail], + vec![], // library_ids - empty means all libraries + None, + "env", + None, + false, + None, + Some(60), // rate_limit_requests_per_minute + ) + .await + .unwrap(); + + assert_eq!(plugin.name, "test_plugin"); + assert_eq!(plugin.display_name, "Test Plugin"); + assert_eq!(plugin.description, Some("A test plugin".to_string())); + assert_eq!(plugin.plugin_type, "system"); + assert_eq!(plugin.command, "node"); + assert!(!plugin.enabled); + assert_eq!(plugin.health_status, "unknown"); + assert_eq!(plugin.failure_count, 0); + assert!(plugin.credentials.is_none()); + } + + #[tokio::test] + async fn test_create_plugin_with_credentials() { + setup_test_encryption_key(); + let db = setup_test_db().await; + + let credentials = serde_json::json!({ + "api_key": "secret-key-123" + }); + + let plugin = PluginsRepository::create( + &db, + "test_plugin", + "Test Plugin", + None, + "system", + "node", + vec![], + vec![], + None, + vec![], + vec![], + vec![], // library_ids - empty means all libraries + Some(&credentials), + "env", + None, + true, + None, + Some(60), // rate_limit_requests_per_minute + ) + .await + .unwrap(); + + assert!(plugin.credentials.is_some()); + assert!(plugin.enabled); + + // Verify credentials can be decrypted + let decrypted = PluginsRepository::get_credentials(&db, plugin.id) + .await + .unwrap() + .unwrap(); + + assert_eq!(decrypted["api_key"], "secret-key-123"); + } + + #[tokio::test] + async fn test_get_by_name() { + setup_test_encryption_key(); + let db = setup_test_db().await; + + PluginsRepository::create( + &db, + "mangabaka", + "MangaBaka", + None, + "system", + "node", + vec![], + vec![], + None, + vec![], + vec![], + vec![], // library_ids - empty means all libraries + None, + "env", + None, + false, + None, + Some(60), // rate_limit_requests_per_minute + ) + .await + .unwrap(); + + let found = PluginsRepository::get_by_name(&db, "mangabaka") + .await + .unwrap(); + assert!(found.is_some()); + assert_eq!(found.unwrap().name, "mangabaka"); + + let not_found = PluginsRepository::get_by_name(&db, "nonexistent") + .await + .unwrap(); + assert!(not_found.is_none()); + } + + #[tokio::test] + async fn test_enable_disable() { + setup_test_encryption_key(); + let db = setup_test_db().await; + + let plugin = PluginsRepository::create( + &db, + "test", + "Test", + None, + "system", + "node", + vec![], + vec![], + None, + vec![], + vec![], + vec![], // library_ids - empty means all libraries + None, + "env", + None, + false, + None, + Some(60), // rate_limit_requests_per_minute + ) + .await + .unwrap(); + + assert!(!plugin.enabled); + + // Enable + let enabled = PluginsRepository::enable(&db, plugin.id, None) + .await + .unwrap(); + assert!(enabled.enabled); + assert_eq!(enabled.health_status, "unknown"); + + // Disable + let disabled = PluginsRepository::disable(&db, plugin.id, None) + .await + .unwrap(); + assert!(!disabled.enabled); + assert_eq!(disabled.health_status, "disabled"); + } + + #[tokio::test] + async fn test_record_success_and_failure() { + setup_test_encryption_key(); + let db = setup_test_db().await; + + let plugin = PluginsRepository::create( + &db, + "test", + "Test", + None, + "system", + "node", + vec![], + vec![], + None, + vec![], + vec![], + vec![], // library_ids - empty means all libraries + None, + "env", + None, + true, + None, + Some(60), // rate_limit_requests_per_minute + ) + .await + .unwrap(); + + // Record failure + let failed = PluginsRepository::record_failure(&db, plugin.id, Some("Connection timeout")) + .await + .unwrap(); + assert_eq!(failed.failure_count, 1); + assert_eq!(failed.health_status, "unhealthy"); + assert!(failed.last_failure_at.is_some()); + + // Record another failure + let failed2 = PluginsRepository::record_failure(&db, plugin.id, None) + .await + .unwrap(); + assert_eq!(failed2.failure_count, 2); + + // Record success - resets failure count + let success = PluginsRepository::record_success(&db, plugin.id) + .await + .unwrap(); + assert_eq!(success.failure_count, 0); + assert_eq!(success.health_status, "healthy"); + assert!(success.last_success_at.is_some()); + } + + #[tokio::test] + async fn test_auto_disable() { + setup_test_encryption_key(); + let db = setup_test_db().await; + + let plugin = PluginsRepository::create( + &db, + "test", + "Test", + None, + "system", + "node", + vec![], + vec![], + None, + vec![], + vec![], + vec![], // library_ids - empty means all libraries + None, + "env", + None, + true, + None, + Some(60), // rate_limit_requests_per_minute + ) + .await + .unwrap(); + + let disabled = PluginsRepository::auto_disable( + &db, + plugin.id, + "Disabled after 3 consecutive failures", + ) + .await + .unwrap(); + + assert!(!disabled.enabled); + assert_eq!(disabled.health_status, "disabled"); + assert_eq!( + disabled.disabled_reason, + Some("Disabled after 3 consecutive failures".to_string()) + ); + + // Check get_auto_disabled + let auto_disabled = PluginsRepository::get_auto_disabled(&db).await.unwrap(); + assert_eq!(auto_disabled.len(), 1); + assert_eq!(auto_disabled[0].id, plugin.id); + } + + #[tokio::test] + async fn test_reset_failure_count() { + setup_test_encryption_key(); + let db = setup_test_db().await; + + let plugin = PluginsRepository::create( + &db, + "test", + "Test", + None, + "system", + "node", + vec![], + vec![], + None, + vec![], + vec![], + vec![], // library_ids - empty means all libraries + None, + "env", + None, + true, + None, + Some(60), // rate_limit_requests_per_minute + ) + .await + .unwrap(); + + // Record some failures + PluginsRepository::record_failure(&db, plugin.id, None) + .await + .unwrap(); + PluginsRepository::record_failure(&db, plugin.id, None) + .await + .unwrap(); + + let failed = PluginsRepository::get_by_id(&db, plugin.id) + .await + .unwrap() + .unwrap(); + assert_eq!(failed.failure_count, 2); + + // Reset + let reset = PluginsRepository::reset_failure_count(&db, plugin.id, None) + .await + .unwrap(); + assert_eq!(reset.failure_count, 0); + assert_eq!(reset.health_status, "unknown"); + assert!(reset.disabled_reason.is_none()); + } + + #[tokio::test] + async fn test_update_credentials() { + setup_test_encryption_key(); + let db = setup_test_db().await; + + let plugin = PluginsRepository::create( + &db, + "test", + "Test", + None, + "system", + "node", + vec![], + vec![], + None, + vec![], + vec![], + vec![], // library_ids - empty means all libraries + Some(&serde_json::json!({"key": "original"})), + "env", + None, + false, + None, + Some(60), // rate_limit_requests_per_minute + ) + .await + .unwrap(); + + // Update credentials + let new_creds = serde_json::json!({"key": "updated"}); + PluginsRepository::update_credentials(&db, plugin.id, Some(&new_creds), None) + .await + .unwrap(); + + let decrypted = PluginsRepository::get_credentials(&db, plugin.id) + .await + .unwrap() + .unwrap(); + assert_eq!(decrypted["key"], "updated"); + + // Clear credentials + PluginsRepository::update_credentials(&db, plugin.id, None, None) + .await + .unwrap(); + + let cleared = PluginsRepository::get_credentials(&db, plugin.id) + .await + .unwrap(); + assert!(cleared.is_none()); + } + + #[tokio::test] + async fn test_update_manifest() { + setup_test_encryption_key(); + let db = setup_test_db().await; + + let plugin = PluginsRepository::create( + &db, + "test", + "Test", + None, + "system", + "node", + vec![], + vec![], + None, + vec![], + vec![], + vec![], // library_ids - empty means all libraries + None, + "env", + None, + false, + None, + Some(60), // rate_limit_requests_per_minute + ) + .await + .unwrap(); + + assert!(plugin.manifest.is_none()); + + let manifest = serde_json::json!({ + "name": "test", + "version": "1.0.0" + }); + + let updated = PluginsRepository::update_manifest(&db, plugin.id, Some(manifest.clone())) + .await + .unwrap(); + + assert!(updated.manifest.is_some()); + assert_eq!(updated.manifest.unwrap()["version"], "1.0.0"); + } + + #[tokio::test] + async fn test_delete() { + setup_test_encryption_key(); + let db = setup_test_db().await; + + let plugin = PluginsRepository::create( + &db, + "test", + "Test", + None, + "system", + "node", + vec![], + vec![], + None, + vec![], + vec![], + vec![], // library_ids - empty means all libraries + None, + "env", + None, + false, + None, + Some(60), // rate_limit_requests_per_minute + ) + .await + .unwrap(); + + let deleted = PluginsRepository::delete(&db, plugin.id).await.unwrap(); + assert!(deleted); + + let found = PluginsRepository::get_by_id(&db, plugin.id).await.unwrap(); + assert!(found.is_none()); + } + + #[tokio::test] + async fn test_get_enabled() { + setup_test_encryption_key(); + let db = setup_test_db().await; + + PluginsRepository::create( + &db, + "enabled1", + "Enabled 1", + None, + "system", + "node", + vec![], + vec![], + None, + vec![], + vec![], + vec![], // library_ids - empty means all libraries + None, + "env", + None, + true, + None, + Some(60), // rate_limit_requests_per_minute + ) + .await + .unwrap(); + + PluginsRepository::create( + &db, + "disabled1", + "Disabled 1", + None, + "system", + "node", + vec![], + vec![], + None, + vec![], + vec![], + vec![], // library_ids - empty means all libraries + None, + "env", + None, + false, + None, + Some(60), // rate_limit_requests_per_minute + ) + .await + .unwrap(); + + let enabled = PluginsRepository::get_enabled(&db).await.unwrap(); + assert_eq!(enabled.len(), 1); + assert_eq!(enabled[0].name, "enabled1"); + } + + #[tokio::test] + async fn test_get_enabled_by_scope() { + setup_test_encryption_key(); + let db = setup_test_db().await; + + PluginsRepository::create( + &db, + "series_plugin", + "Series Plugin", + None, + "system", + "node", + vec![], + vec![], + None, + vec![], + vec![PluginScope::SeriesDetail, PluginScope::SeriesBulk], + vec![], // library_ids - empty means all libraries + None, + "env", + None, + true, + None, + Some(60), // rate_limit_requests_per_minute + ) + .await + .unwrap(); + + PluginsRepository::create( + &db, + "library_plugin", + "Library Plugin", + None, + "system", + "node", + vec![], + vec![], + None, + vec![], + vec![PluginScope::LibraryDetail], + vec![], // library_ids - empty means all libraries + None, + "env", + None, + true, + None, + Some(60), // rate_limit_requests_per_minute + ) + .await + .unwrap(); + + let series_plugins = + PluginsRepository::get_enabled_by_scope(&db, &PluginScope::SeriesDetail) + .await + .unwrap(); + assert_eq!(series_plugins.len(), 1); + assert_eq!(series_plugins[0].name, "series_plugin"); + + let library_plugins = + PluginsRepository::get_enabled_by_scope(&db, &PluginScope::LibraryDetail) + .await + .unwrap(); + assert_eq!(library_plugins.len(), 1); + assert_eq!(library_plugins[0].name, "library_plugin"); + + let bulk_plugins = PluginsRepository::get_enabled_by_scope(&db, &PluginScope::SeriesBulk) + .await + .unwrap(); + assert_eq!(bulk_plugins.len(), 1); + } + + #[tokio::test] + async fn test_permissions_and_scopes() { + setup_test_encryption_key(); + let db = setup_test_db().await; + + let plugin = PluginsRepository::create( + &db, + "test", + "Test", + None, + "system", + "node", + vec![], + vec![], + None, + vec![ + PluginPermission::MetadataWriteSummary, + PluginPermission::MetadataWriteGenres, + ], + vec![PluginScope::SeriesDetail], + vec![], // library_ids - empty means all libraries + None, + "env", + None, + false, + None, + Some(60), // rate_limit_requests_per_minute + ) + .await + .unwrap(); + + // Test permissions parsing + let permissions = plugin.permissions_vec(); + assert_eq!(permissions.len(), 2); + assert!(permissions.contains(&PluginPermission::MetadataWriteSummary)); + assert!(permissions.contains(&PluginPermission::MetadataWriteGenres)); + + // Test has_permission + assert!(plugin.has_permission(&PluginPermission::MetadataWriteSummary)); + assert!(!plugin.has_permission(&PluginPermission::MetadataWriteTitle)); + + // Test scopes parsing + let scopes = plugin.scopes_vec(); + assert_eq!(scopes.len(), 1); + assert!(scopes.contains(&PluginScope::SeriesDetail)); + + // Test has_scope + assert!(plugin.has_scope(&PluginScope::SeriesDetail)); + assert!(!plugin.has_scope(&PluginScope::LibraryDetail)); + } + + #[tokio::test] + async fn test_wildcard_permission() { + setup_test_encryption_key(); + let db = setup_test_db().await; + + let plugin = PluginsRepository::create( + &db, + "test", + "Test", + None, + "system", + "node", + vec![], + vec![], + None, + vec![PluginPermission::MetadataWriteAll], + vec![], + vec![], // library_ids - empty means all libraries + None, + "env", + None, + false, + None, + Some(60), // rate_limit_requests_per_minute + ) + .await + .unwrap(); + + // Wildcard should grant all write permissions + assert!(plugin.has_permission(&PluginPermission::MetadataWriteTitle)); + assert!(plugin.has_permission(&PluginPermission::MetadataWriteSummary)); + assert!(plugin.has_permission(&PluginPermission::MetadataWriteGenres)); + assert!(plugin.has_permission(&PluginPermission::MetadataWriteTags)); + + // But not read permissions or library permissions + assert!(!plugin.has_permission(&PluginPermission::MetadataRead)); + assert!(!plugin.has_permission(&PluginPermission::LibraryRead)); + } + + #[tokio::test] + async fn test_get_enabled_by_scope_and_library() { + setup_test_encryption_key(); + let db = setup_test_db().await; + + let manga_library_id = Uuid::new_v4(); + let comics_library_id = Uuid::new_v4(); + + // Create a plugin that applies to all libraries + PluginsRepository::create( + &db, + "all_libraries_plugin", + "All Libraries Plugin", + None, + "system", + "node", + vec![], + vec![], + None, + vec![], + vec![PluginScope::SeriesDetail], + vec![], // empty = all libraries + None, + "env", + None, + true, + None, + Some(60), // rate_limit_requests_per_minute + ) + .await + .unwrap(); + + // Create a plugin that only applies to manga library + PluginsRepository::create( + &db, + "manga_only_plugin", + "Manga Only Plugin", + None, + "system", + "node", + vec![], + vec![], + None, + vec![], + vec![PluginScope::SeriesDetail], + vec![manga_library_id], // only manga library + None, + "env", + None, + true, + None, + Some(60), // rate_limit_requests_per_minute + ) + .await + .unwrap(); + + // Create a plugin that applies to both manga and comics + PluginsRepository::create( + &db, + "manga_comics_plugin", + "Manga & Comics Plugin", + None, + "system", + "node", + vec![], + vec![], + None, + vec![], + vec![PluginScope::SeriesDetail], + vec![manga_library_id, comics_library_id], + None, + "env", + None, + true, + None, + Some(60), // rate_limit_requests_per_minute + ) + .await + .unwrap(); + + // Query for manga library - should get all 3 plugins + let manga_plugins = PluginsRepository::get_enabled_by_scope_and_library( + &db, + &PluginScope::SeriesDetail, + manga_library_id, + ) + .await + .unwrap(); + assert_eq!(manga_plugins.len(), 3); + + // Query for comics library - should get 2 plugins (all_libraries and manga_comics) + let comics_plugins = PluginsRepository::get_enabled_by_scope_and_library( + &db, + &PluginScope::SeriesDetail, + comics_library_id, + ) + .await + .unwrap(); + assert_eq!(comics_plugins.len(), 2); + let names: Vec<&str> = comics_plugins.iter().map(|p| p.name.as_str()).collect(); + assert!(names.contains(&"all_libraries_plugin")); + assert!(names.contains(&"manga_comics_plugin")); + assert!(!names.contains(&"manga_only_plugin")); + + // Query for an unknown library - should only get the all_libraries plugin + let unknown_library_id = Uuid::new_v4(); + let unknown_plugins = PluginsRepository::get_enabled_by_scope_and_library( + &db, + &PluginScope::SeriesDetail, + unknown_library_id, + ) + .await + .unwrap(); + assert_eq!(unknown_plugins.len(), 1); + assert_eq!(unknown_plugins[0].name, "all_libraries_plugin"); + } + + #[tokio::test] + async fn test_get_all_paginated() { + setup_test_encryption_key(); + let db = setup_test_db().await; + + // Create 5 plugins (3 enabled, 2 disabled) + for i in 0..5 { + PluginsRepository::create( + &db, + &format!("plugin_{}", i), + &format!("Plugin {}", i), + None, + "system", + "node", + vec![], + vec![], + None, + vec![], + vec![], + vec![], + None, + "env", + None, + i < 3, // First 3 enabled + None, + Some(60), + ) + .await + .unwrap(); + } + + // Get first page (2 plugins) + let (plugins, total) = PluginsRepository::get_all_paginated(&db, 2, 0) + .await + .unwrap(); + assert_eq!(total, 5); + assert_eq!(plugins.len(), 2); + assert_eq!(plugins[0].name, "plugin_0"); + assert_eq!(plugins[1].name, "plugin_1"); + + // Get second page + let (plugins, total) = PluginsRepository::get_all_paginated(&db, 2, 2) + .await + .unwrap(); + assert_eq!(total, 5); + assert_eq!(plugins.len(), 2); + assert_eq!(plugins[0].name, "plugin_2"); + + // Get third page (only 1 remaining) + let (plugins, total) = PluginsRepository::get_all_paginated(&db, 2, 4) + .await + .unwrap(); + assert_eq!(total, 5); + assert_eq!(plugins.len(), 1); + + // Get count only (limit=0) + let (plugins, total) = PluginsRepository::get_all_paginated(&db, 0, 0) + .await + .unwrap(); + assert_eq!(total, 5); + assert!(plugins.is_empty()); + } + + #[tokio::test] + async fn test_get_enabled_paginated() { + setup_test_encryption_key(); + let db = setup_test_db().await; + + // Create 5 plugins (3 enabled, 2 disabled) + for i in 0..5 { + PluginsRepository::create( + &db, + &format!("plugin_{}", i), + &format!("Plugin {}", i), + None, + "system", + "node", + vec![], + vec![], + None, + vec![], + vec![], + vec![], + None, + "env", + None, + i < 3, // First 3 enabled + None, + Some(60), + ) + .await + .unwrap(); + } + + // Get first page of enabled plugins + let (plugins, total) = PluginsRepository::get_enabled_paginated(&db, 2, 0) + .await + .unwrap(); + assert_eq!(total, 3); // Only 3 are enabled + assert_eq!(plugins.len(), 2); + + // Get second page + let (plugins, total) = PluginsRepository::get_enabled_paginated(&db, 2, 2) + .await + .unwrap(); + assert_eq!(total, 3); + assert_eq!(plugins.len(), 1); // Only 1 remaining + + // Get count only (limit=0) + let (plugins, total) = PluginsRepository::get_enabled_paginated(&db, 0, 0) + .await + .unwrap(); + assert_eq!(total, 3); + assert!(plugins.is_empty()); + } +} diff --git a/src/db/repositories/series_metadata.rs b/src/db/repositories/series_metadata.rs index b8609ca1..025b5a32 100644 --- a/src/db/repositories/series_metadata.rs +++ b/src/db/repositories/series_metadata.rs @@ -293,6 +293,26 @@ impl SeriesMetadataRepository { Ok(model) } + /// Update total book count (expected number of books in the series) + pub async fn update_total_book_count( + db: &DatabaseConnection, + series_id: Uuid, + total_book_count: Option, + ) -> Result { + let existing = Self::get_by_series_id(db, series_id) + .await? + .ok_or_else(|| { + anyhow::anyhow!("Series metadata not found for series: {}", series_id) + })?; + + let mut active_model: series_metadata::ActiveModel = existing.into(); + active_model.total_book_count = Set(total_book_count); + active_model.updated_at = Set(Utc::now()); + + let model = active_model.update(db).await?; + Ok(model) + } + /// Lock or unlock a specific metadata field pub async fn set_lock( db: &DatabaseConnection, diff --git a/src/events/types.rs b/src/events/types.rs index d941d544..46723028 100644 --- a/src/events/types.rs +++ b/src/events/types.rs @@ -82,6 +82,15 @@ pub enum EntityEvent { }, /// A series was deleted SeriesDeleted { series_id: Uuid, library_id: Uuid }, + /// Series metadata was updated by a plugin + SeriesMetadataUpdated { + series_id: Uuid, + library_id: Uuid, + /// Plugin that updated the metadata + plugin_id: Uuid, + /// Fields that were updated + fields_updated: Vec, + }, /// Deleted books were purged from a series SeriesBulkPurged { series_id: Uuid, @@ -136,6 +145,7 @@ impl EntityChangeEvent { | EntityEvent::SeriesCreated { library_id, .. } | EntityEvent::SeriesUpdated { library_id, .. } | EntityEvent::SeriesDeleted { library_id, .. } + | EntityEvent::SeriesMetadataUpdated { library_id, .. } | EntityEvent::SeriesBulkPurged { library_id, .. } | EntityEvent::LibraryUpdated { library_id } | EntityEvent::LibraryDeleted { library_id } => Some(*library_id), diff --git a/src/scheduler/mod.rs b/src/scheduler/mod.rs index 15ea87bf..ad766fd6 100644 --- a/src/scheduler/mod.rs +++ b/src/scheduler/mod.rs @@ -38,6 +38,10 @@ impl Scheduler { // Load PDF cache cleanup schedule self.load_pdf_cache_cleanup_schedule().await?; + // Load thumbnail generation schedules + self.load_book_thumbnail_schedule().await?; + self.load_series_thumbnail_schedule().await?; + // Start the scheduler self.scheduler .start() @@ -179,6 +183,104 @@ impl Scheduler { Ok(()) } + /// Load book thumbnail generation schedule from settings + /// + /// This job generates thumbnails for all books that don't have one. + /// It uses the GenerateThumbnails task type which fans out to individual book tasks. + async fn load_book_thumbnail_schedule(&mut self) -> Result<()> { + let settings = SettingsService::new(self.db.clone()).await?; + + // Get cron schedule (empty string = disabled) + let cron = settings + .get_string("thumbnail.book_cron_schedule", "") + .await?; + + if cron.is_empty() { + debug!("Book thumbnail generation disabled (no cron schedule)"); + return Ok(()); + } + + // Create cron job + let db = self.db.clone(); + let job = Job::new_async(cron.as_str(), move |_uuid, _lock| { + let db = db.clone(); + Box::pin(async move { + info!("Triggering scheduled book thumbnail generation"); + + // GenerateThumbnails with no library_id/series_id will process all books + let task_type = TaskType::GenerateThumbnails { + library_id: None, + series_id: None, + force: false, // Only generate missing thumbnails + }; + + match TaskRepository::enqueue(&db, task_type, 0, None).await { + Ok(_) => debug!("Book thumbnail generation task enqueued"), + Err(e) => error!("Failed to enqueue book thumbnail generation: {}", e), + } + }) + }) + .context("Failed to create book thumbnail generation cron job")?; + + self.scheduler + .add(job) + .await + .context("Failed to add book thumbnail generation job to scheduler")?; + + info!("Added book thumbnail generation schedule: {}", cron); + + Ok(()) + } + + /// Load series thumbnail generation schedule from settings + /// + /// This job generates thumbnails for all series that don't have one. + /// It enqueues a GenerateSeriesThumbnails fan-out task that handles + /// filtering and enqueueing individual GenerateSeriesThumbnail tasks. + async fn load_series_thumbnail_schedule(&mut self) -> Result<()> { + let settings = SettingsService::new(self.db.clone()).await?; + + // Get cron schedule (empty string = disabled) + let cron = settings + .get_string("thumbnail.series_cron_schedule", "") + .await?; + + if cron.is_empty() { + debug!("Series thumbnail generation disabled (no cron schedule)"); + return Ok(()); + } + + // Create cron job + let db = self.db.clone(); + let job = Job::new_async(cron.as_str(), move |_uuid, _lock| { + let db = db.clone(); + Box::pin(async move { + info!("Triggering scheduled series thumbnail generation"); + + // Enqueue fan-out task that will filter and enqueue individual tasks + let task_type = TaskType::GenerateSeriesThumbnails { + library_id: None, + force: false, // Only generate missing thumbnails + }; + + match TaskRepository::enqueue(&db, task_type, 0, None).await { + Ok(_) => debug!("Series thumbnail generation task enqueued"), + Err(e) => error!("Failed to enqueue series thumbnail generation: {}", e), + } + }) + }) + .context("Failed to create series thumbnail generation cron job")?; + + self.scheduler + .add(job) + .await + .context("Failed to add series thumbnail generation job to scheduler")?; + + info!("Added series thumbnail generation schedule: {}", cron); + + Ok(()) + } + /// Add or update a library's schedule pub async fn add_library_schedule(&mut self, library_id: Uuid) -> Result<()> { // Load library from database diff --git a/src/services/metadata/apply.rs b/src/services/metadata/apply.rs new file mode 100644 index 00000000..6df12c0c --- /dev/null +++ b/src/services/metadata/apply.rs @@ -0,0 +1,535 @@ +//! Shared metadata application service. +//! +//! This module provides a unified implementation for applying plugin metadata to series, +//! used by both synchronous API endpoints and background task handlers. + +use anyhow::{Context, Result}; +use sea_orm::prelude::Decimal; +use sea_orm::DatabaseConnection; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use tracing::warn; +use uuid::Uuid; + +use crate::db::entities::plugins::{Model as Plugin, PluginPermission}; +use crate::db::entities::series_metadata::Model as SeriesMetadata; +use crate::db::entities::SeriesStatus; +use crate::db::repositories::{ + AlternateTitleRepository, ExternalLinkRepository, ExternalRatingRepository, GenreRepository, + SeriesMetadataRepository, TagRepository, +}; +use crate::events::EventBroadcaster; +use crate::services::plugin::PluginSeriesMetadata; +use crate::services::ThumbnailService; + +use super::CoverService; + +/// A field that was skipped during metadata application. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SkippedField { + pub field: String, + pub reason: String, +} + +/// Result of applying metadata to a series. +#[derive(Debug, Clone)] +pub struct MetadataApplyResult { + /// Fields that were successfully applied. + pub applied_fields: Vec, + /// Fields that were skipped (with reasons). + pub skipped_fields: Vec, +} + +/// Options for controlling metadata application behavior. +#[derive(Clone, Default)] +pub struct ApplyOptions { + /// If Some, only apply fields in this set. If None, apply all fields. + pub fields_filter: Option>, + /// Thumbnail service for downloading covers. If None, covers will be skipped. + pub thumbnail_service: Option>, + /// Event broadcaster for emitting real-time events. If None, events won't be emitted. + pub event_broadcaster: Option>, +} + +/// Service for applying plugin metadata to series. +pub struct MetadataApplier; + +impl MetadataApplier { + /// Apply metadata from a plugin to a series. + /// + /// This function applies all metadata fields from the plugin, respecting: + /// - Field locks (user has locked the field from being updated) + /// - Plugin permissions (plugin is not allowed to update this field) + /// - Optional field filtering (only apply specific fields) + pub async fn apply( + db: &DatabaseConnection, + series_id: Uuid, + library_id: Uuid, + plugin: &Plugin, + metadata: &PluginSeriesMetadata, + current_metadata: Option<&SeriesMetadata>, + options: &ApplyOptions, + ) -> Result { + let mut applied_fields = Vec::new(); + let mut skipped_fields = Vec::new(); + + // Helper to check if a field should be applied based on the filter + let should_apply_field = |field: &str| -> bool { + options + .fields_filter + .as_ref() + .is_none_or(|filter| filter.contains(field)) + }; + + // Helper to check permission and lock + let check_field = |field: &str, + is_locked: bool, + permission: PluginPermission| + -> Result { + if is_locked { + Err(SkippedField { + field: field.to_string(), + reason: "Field is locked".to_string(), + }) + } else if !plugin.has_permission(&permission) { + Err(SkippedField { + field: field.to_string(), + reason: "Plugin does not have permission".to_string(), + }) + } else { + Ok(true) + } + }; + + // Title + if should_apply_field("title") { + if let Some(title) = &metadata.title { + let is_locked = current_metadata.map(|m| m.title_lock).unwrap_or(false); + match check_field("title", is_locked, PluginPermission::MetadataWriteTitle) { + Ok(_) => { + let title_sort = current_metadata.and_then(|m| m.title_sort.clone()); + SeriesMetadataRepository::update_title( + db, + series_id, + title.clone(), + title_sort, + ) + .await + .context("Failed to update title")?; + applied_fields.push("title".to_string()); + } + Err(skip) => skipped_fields.push(skip), + } + } + } + + // Alternate Titles + if should_apply_field("alternateTitles") && !metadata.alternate_titles.is_empty() { + let is_locked = current_metadata.map(|m| m.title_lock).unwrap_or(false); + match check_field( + "alternateTitles", + is_locked, + PluginPermission::MetadataWriteTitle, + ) { + Ok(_) => { + // Delete existing alternate titles + AlternateTitleRepository::delete_all_for_series(db, series_id) + .await + .context("Failed to delete old alternate titles")?; + + // Add new alternate titles with unique labels + // Track label counts to make duplicates unique (e.g., "en", "en-2", "en-3") + let mut label_counts: HashMap = HashMap::new(); + + for alt_title in &metadata.alternate_titles { + // Use language or title_type as base label, defaulting to "alternate" + let base_label = alt_title + .language + .clone() + .or_else(|| alt_title.title_type.clone()) + .unwrap_or_else(|| "alternate".to_string()); + + // Make label unique by appending count suffix for duplicates + let count = label_counts.entry(base_label.clone()).or_insert(0); + *count += 1; + let label = if *count == 1 { + base_label + } else { + format!("{}-{}", base_label, count) + }; + + AlternateTitleRepository::create(db, series_id, &label, &alt_title.title) + .await + .context("Failed to create alternate title")?; + } + applied_fields.push("alternateTitles".to_string()); + } + Err(skip) => skipped_fields.push(skip), + } + } + + // Summary + if should_apply_field("summary") { + if let Some(summary) = &metadata.summary { + let is_locked = current_metadata.map(|m| m.summary_lock).unwrap_or(false); + match check_field("summary", is_locked, PluginPermission::MetadataWriteSummary) { + Ok(_) => { + SeriesMetadataRepository::update_summary( + db, + series_id, + Some(summary.clone()), + ) + .await + .context("Failed to update summary")?; + applied_fields.push("summary".to_string()); + } + Err(skip) => skipped_fields.push(skip), + } + } + } + + // Year + if should_apply_field("year") { + if let Some(year) = metadata.year { + let is_locked = current_metadata.map(|m| m.year_lock).unwrap_or(false); + match check_field("year", is_locked, PluginPermission::MetadataWriteYear) { + Ok(_) => { + SeriesMetadataRepository::update_year(db, series_id, Some(year)) + .await + .context("Failed to update year")?; + applied_fields.push("year".to_string()); + } + Err(skip) => skipped_fields.push(skip), + } + } + } + + // Status + if should_apply_field("status") { + if let Some(status) = &metadata.status { + let is_locked = current_metadata.map(|m| m.status_lock).unwrap_or(false); + match check_field("status", is_locked, PluginPermission::MetadataWriteStatus) { + Ok(_) => { + let status_str = match status { + SeriesStatus::Ongoing => "ongoing", + SeriesStatus::Ended => "ended", + SeriesStatus::Hiatus => "hiatus", + SeriesStatus::Abandoned => "abandoned", + SeriesStatus::Unknown => "unknown", + }; + SeriesMetadataRepository::update_status( + db, + series_id, + Some(status_str.to_string()), + ) + .await + .context("Failed to update status")?; + applied_fields.push("status".to_string()); + } + Err(skip) => skipped_fields.push(skip), + } + } + } + + // Publisher + if should_apply_field("publisher") { + if let Some(publisher) = &metadata.publisher { + let is_locked = current_metadata.map(|m| m.publisher_lock).unwrap_or(false); + match check_field( + "publisher", + is_locked, + PluginPermission::MetadataWritePublisher, + ) { + Ok(_) => { + SeriesMetadataRepository::update_publisher( + db, + series_id, + Some(publisher.clone()), + None, + ) + .await + .context("Failed to update publisher")?; + applied_fields.push("publisher".to_string()); + } + Err(skip) => skipped_fields.push(skip), + } + } + } + + // Age Rating + if should_apply_field("ageRating") { + if let Some(age_rating) = metadata.age_rating { + let is_locked = current_metadata.map(|m| m.age_rating_lock).unwrap_or(false); + match check_field( + "ageRating", + is_locked, + PluginPermission::MetadataWriteAgeRating, + ) { + Ok(_) => { + SeriesMetadataRepository::update_age_rating( + db, + series_id, + Some(age_rating), + ) + .await + .context("Failed to update age rating")?; + applied_fields.push("ageRating".to_string()); + } + Err(skip) => skipped_fields.push(skip), + } + } + } + + // Language + if should_apply_field("language") { + if let Some(language) = &metadata.language { + let is_locked = current_metadata.map(|m| m.language_lock).unwrap_or(false); + match check_field( + "language", + is_locked, + PluginPermission::MetadataWriteLanguage, + ) { + Ok(_) => { + SeriesMetadataRepository::update_language( + db, + series_id, + Some(language.clone()), + ) + .await + .context("Failed to update language")?; + applied_fields.push("language".to_string()); + } + Err(skip) => skipped_fields.push(skip), + } + } + } + + // Reading Direction + if should_apply_field("readingDirection") { + if let Some(reading_direction) = &metadata.reading_direction { + let is_locked = current_metadata + .map(|m| m.reading_direction_lock) + .unwrap_or(false); + match check_field( + "readingDirection", + is_locked, + PluginPermission::MetadataWriteReadingDirection, + ) { + Ok(_) => { + SeriesMetadataRepository::update_reading_direction( + db, + series_id, + Some(reading_direction.clone()), + ) + .await + .context("Failed to update reading direction")?; + applied_fields.push("readingDirection".to_string()); + } + Err(skip) => skipped_fields.push(skip), + } + } + } + + // Total Book Count + if should_apply_field("totalBookCount") { + if let Some(total_book_count) = metadata.total_book_count { + let is_locked = current_metadata + .map(|m| m.total_book_count_lock) + .unwrap_or(false); + match check_field( + "totalBookCount", + is_locked, + PluginPermission::MetadataWriteTotalBookCount, + ) { + Ok(_) => { + SeriesMetadataRepository::update_total_book_count( + db, + series_id, + Some(total_book_count), + ) + .await + .context("Failed to update total book count")?; + applied_fields.push("totalBookCount".to_string()); + } + Err(skip) => skipped_fields.push(skip), + } + } + } + + // Genres - uses set_genres_for_series which replaces all + if should_apply_field("genres") && !metadata.genres.is_empty() { + let is_locked = current_metadata.map(|m| m.genres_lock).unwrap_or(false); + if is_locked { + skipped_fields.push(SkippedField { + field: "genres".to_string(), + reason: "Field is locked".to_string(), + }); + } else if !plugin.has_permission(&PluginPermission::MetadataWriteGenres) { + skipped_fields.push(SkippedField { + field: "genres".to_string(), + reason: "Plugin does not have permission".to_string(), + }); + } else { + GenreRepository::set_genres_for_series(db, series_id, metadata.genres.clone()) + .await + .context("Failed to set genres")?; + applied_fields.push("genres".to_string()); + } + } + + // Tags - uses set_tags_for_series which replaces all + if should_apply_field("tags") && !metadata.tags.is_empty() { + let is_locked = current_metadata.map(|m| m.tags_lock).unwrap_or(false); + if is_locked { + skipped_fields.push(SkippedField { + field: "tags".to_string(), + reason: "Field is locked".to_string(), + }); + } else if !plugin.has_permission(&PluginPermission::MetadataWriteTags) { + skipped_fields.push(SkippedField { + field: "tags".to_string(), + reason: "Plugin does not have permission".to_string(), + }); + } else { + TagRepository::set_tags_for_series(db, series_id, metadata.tags.clone()) + .await + .context("Failed to set tags")?; + applied_fields.push("tags".to_string()); + } + } + + // Authors - not yet implemented in series_metadata + if should_apply_field("authors") && !metadata.authors.is_empty() { + skipped_fields.push(SkippedField { + field: "authors".to_string(), + reason: "Authors field not yet implemented".to_string(), + }); + } + + // Artists - not yet implemented in series_metadata + if should_apply_field("artists") && !metadata.artists.is_empty() { + skipped_fields.push(SkippedField { + field: "artists".to_string(), + reason: "Artists field not yet implemented".to_string(), + }); + } + + // External Links + if should_apply_field("externalLinks") && !metadata.external_links.is_empty() { + if !plugin.has_permission(&PluginPermission::MetadataWriteLinks) { + skipped_fields.push(SkippedField { + field: "externalLinks".to_string(), + reason: "Plugin does not have permission".to_string(), + }); + } else { + for link in &metadata.external_links { + ExternalLinkRepository::upsert(db, series_id, &link.label, &link.url, None) + .await + .context("Failed to upsert external link")?; + } + applied_fields.push("externalLinks".to_string()); + } + } + + // External Ratings (primary rating from plugin) + if should_apply_field("rating") { + if let Some(rating) = &metadata.rating { + if !plugin.has_permission(&PluginPermission::MetadataWriteRatings) { + skipped_fields.push(SkippedField { + field: "rating".to_string(), + reason: "Plugin does not have permission".to_string(), + }); + } else { + let score = Decimal::from_f64_retain(rating.score) + .unwrap_or_else(|| Decimal::new(0, 0)); + ExternalRatingRepository::upsert( + db, + series_id, + &rating.source, + score, + rating.vote_count, + ) + .await + .context("Failed to upsert external rating")?; + applied_fields.push("rating".to_string()); + } + } + } + + // Multiple external ratings + if should_apply_field("externalRatings") && !metadata.external_ratings.is_empty() { + if !plugin.has_permission(&PluginPermission::MetadataWriteRatings) { + if !skipped_fields.iter().any(|f| f.field == "rating") { + skipped_fields.push(SkippedField { + field: "externalRatings".to_string(), + reason: "Plugin does not have permission".to_string(), + }); + } + } else { + for rating in &metadata.external_ratings { + let score = Decimal::from_f64_retain(rating.score) + .unwrap_or_else(|| Decimal::new(0, 0)); + ExternalRatingRepository::upsert( + db, + series_id, + &rating.source, + score, + rating.vote_count, + ) + .await + .context("Failed to upsert external rating")?; + } + if !applied_fields.contains(&"rating".to_string()) { + applied_fields.push("externalRatings".to_string()); + } + } + } + + // Cover URL - download and apply cover from plugin + if should_apply_field("coverUrl") { + if let Some(cover_url) = &metadata.cover_url { + if !plugin.has_permission(&PluginPermission::MetadataWriteCovers) { + skipped_fields.push(SkippedField { + field: "coverUrl".to_string(), + reason: "Plugin does not have permission".to_string(), + }); + } else if let Some(thumbnail_service) = &options.thumbnail_service { + match CoverService::download_and_apply( + db, + thumbnail_service, + series_id, + library_id, + cover_url, + &plugin.name, + options.event_broadcaster.as_ref(), + ) + .await + { + Ok(_) => { + applied_fields.push("coverUrl".to_string()); + } + Err(e) => { + warn!("Failed to download cover: {}", e); + skipped_fields.push(SkippedField { + field: "coverUrl".to_string(), + reason: format!("Failed to download cover: {}", e), + }); + } + } + } else { + skipped_fields.push(SkippedField { + field: "coverUrl".to_string(), + reason: "ThumbnailService not available".to_string(), + }); + } + } + } + + Ok(MetadataApplyResult { + applied_fields, + skipped_fields, + }) + } +} diff --git a/src/services/metadata/cover.rs b/src/services/metadata/cover.rs new file mode 100644 index 00000000..17d4b8e2 --- /dev/null +++ b/src/services/metadata/cover.rs @@ -0,0 +1,164 @@ +//! Cover download and application service. +//! +//! Handles downloading cover images from URLs and storing them in the database. + +use anyhow::{Context, Result}; +use chrono::Utc; +use sea_orm::DatabaseConnection; +use std::sync::Arc; +use tracing::warn; +use uuid::Uuid; + +use crate::db::repositories::{SeriesCoversRepository, SeriesRepository, TaskRepository}; +use crate::events::{EntityChangeEvent, EntityEvent, EntityType, EventBroadcaster}; +use crate::services::ThumbnailService; +use crate::tasks::types::TaskType; + +/// Service for downloading and applying cover images to series. +pub struct CoverService; + +impl CoverService { + /// Download a cover from URL and apply it to a series. + /// + /// If a cover from this plugin already exists, it will be replaced with the new one. + /// This ensures the cover is always up-to-date with what the plugin provides. + pub async fn download_and_apply( + db: &DatabaseConnection, + thumbnail_service: &ThumbnailService, + series_id: Uuid, + library_id: Uuid, + cover_url: &str, + plugin_name: &str, + event_broadcaster: Option<&Arc>, + ) -> Result<()> { + use tokio::fs; + use tokio::io::AsyncWriteExt; + + // Check if a cover from this plugin already exists for this series + let source = format!("plugin:{}", plugin_name); + let existing_cover = SeriesCoversRepository::get_by_source(db, series_id, &source).await?; + + // Delete existing cover from this plugin if present + if let Some(existing) = existing_cover { + // Delete the old cover file + if let Err(e) = fs::remove_file(&existing.path).await { + warn!("Failed to delete old cover file {}: {}", existing.path, e); + } + // Delete the database record + SeriesCoversRepository::delete(db, existing.id).await?; + } + + // Download the image using reqwest + let response = reqwest::get(cover_url) + .await + .context("Failed to download cover")?; + + if !response.status().is_success() { + anyhow::bail!("Failed to download cover: HTTP {}", response.status()); + } + + let image_data = response + .bytes() + .await + .context("Failed to read cover data")? + .to_vec(); + + // Validate that it's a valid image + image::load_from_memory(&image_data).context("Invalid image file")?; + + // Compute hash of image data for deduplication + let image_hash = crate::utils::hasher::hash_bytes(&image_data); + let short_hash = &image_hash[..16]; + + // Create covers directory within uploads dir if it doesn't exist + let covers_dir = thumbnail_service.get_uploads_dir().join("covers"); + fs::create_dir_all(&covers_dir) + .await + .context("Failed to create covers directory")?; + + // Use series_id and image hash for filename + let filename = format!("{}-{}.jpg", series_id, short_hash); + let filepath = covers_dir.join(&filename); + + // Write the image file + let mut file = fs::File::create(&filepath) + .await + .context("Failed to create cover file")?; + + file.write_all(&image_data) + .await + .context("Failed to write cover file")?; + + // Create a new cover with source = "plugin:{plugin_name}" + // This automatically deselects any previously selected cover + SeriesCoversRepository::create( + db, + series_id, + &source, + &filepath.to_string_lossy(), + true, // is_selected + None, + None, + ) + .await + .context("Failed to create cover record")?; + + // Touch series to update updated_at (for cache busting) + SeriesRepository::touch(db, series_id).await?; + + // Queue thumbnail regeneration task + Self::queue_thumbnail_regeneration(db, thumbnail_service, series_id).await; + + // Emit CoverUpdated event for real-time UI updates + Self::emit_cover_updated_event(event_broadcaster, series_id, library_id); + + Ok(()) + } + + /// Queue a task to regenerate the series thumbnail. + async fn queue_thumbnail_regeneration( + db: &DatabaseConnection, + thumbnail_service: &ThumbnailService, + series_id: Uuid, + ) { + // Delete cached thumbnail first + if let Err(e) = thumbnail_service.delete_series_thumbnail(series_id).await { + warn!( + "Failed to delete series thumbnail cache for {}: {}", + series_id, e + ); + } + + // Queue regeneration task + let task_type = TaskType::GenerateSeriesThumbnail { + series_id, + force: true, + }; + if let Err(e) = TaskRepository::enqueue(db, task_type, 0, None).await { + warn!( + "Failed to queue series thumbnail regeneration task for {}: {}", + series_id, e + ); + } + } + + /// Emit a CoverUpdated event for real-time UI updates. + fn emit_cover_updated_event( + event_broadcaster: Option<&Arc>, + series_id: Uuid, + library_id: Uuid, + ) { + if let Some(broadcaster) = event_broadcaster { + let event = EntityChangeEvent { + event: EntityEvent::CoverUpdated { + entity_type: EntityType::Series, + entity_id: series_id, + library_id: Some(library_id), + }, + timestamp: Utc::now(), + user_id: None, + }; + let _ = broadcaster.emit(event); + } + } +} diff --git a/src/services/metadata/mod.rs b/src/services/metadata/mod.rs new file mode 100644 index 00000000..fdae0768 --- /dev/null +++ b/src/services/metadata/mod.rs @@ -0,0 +1,10 @@ +//! Shared metadata services for applying plugin metadata to series. +//! +//! This module provides a unified implementation for applying metadata from plugins, +//! used by both synchronous API endpoints and background task handlers. + +mod apply; +mod cover; + +pub use apply::{ApplyOptions, MetadataApplier, SkippedField}; +pub use cover::CoverService; diff --git a/src/services/mod.rs b/src/services/mod.rs index b18cb628..fd0fc27a 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -4,7 +4,10 @@ pub mod email; pub mod file_cleanup; pub mod filter; pub mod inflight_thumbnails; +pub mod metadata; pub mod pdf_cache; +pub mod plugin; +pub mod plugin_metrics; pub mod rate_limiter; pub mod read_progress; pub mod settings; @@ -24,3 +27,6 @@ pub use settings::SettingsService; pub use task_listener::TaskListener; pub use task_metrics::TaskMetricsService; pub use thumbnail::ThumbnailService; + +pub use plugin::encryption::CredentialEncryption; +pub use plugin_metrics::{PluginHealthStatus, PluginMetricsService}; diff --git a/src/services/plugin/encryption.rs b/src/services/plugin/encryption.rs new file mode 100644 index 00000000..4bb74af9 --- /dev/null +++ b/src/services/plugin/encryption.rs @@ -0,0 +1,311 @@ +//! Credential encryption service using AES-256-GCM +//! +//! All sensitive credentials (API keys, OAuth tokens) are encrypted at rest +//! using AES-256-GCM with a 96-bit nonce prepended to the ciphertext. + +use aes_gcm::{ + aead::{Aead, KeyInit}, + Aes256Gcm, Key, Nonce, +}; +use anyhow::{anyhow, Result}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use rand::RngCore; +use std::env; +use std::sync::OnceLock; + +/// Global encryption service instance +static ENCRYPTION_SERVICE: OnceLock = OnceLock::new(); + +/// The environment variable name for the encryption key +pub const ENCRYPTION_KEY_ENV: &str = "CODEX_ENCRYPTION_KEY"; + +/// Credential encryption service using AES-256-GCM +#[derive(Clone)] +pub struct CredentialEncryption { + cipher: Aes256Gcm, +} + +#[allow(dead_code)] +impl CredentialEncryption { + /// Create a new encryption service with the given 256-bit key + pub fn new(key: &[u8; 32]) -> Self { + let key = Key::::from_slice(key); + Self { + cipher: Aes256Gcm::new(key), + } + } + + /// Create encryption service from a base64-encoded key + pub fn from_base64_key(base64_key: &str) -> Result { + let key_bytes = BASE64 + .decode(base64_key) + .map_err(|e| anyhow!("Invalid base64 encryption key: {}", e))?; + + if key_bytes.len() != 32 { + return Err(anyhow!( + "Encryption key must be 32 bytes (256 bits), got {} bytes", + key_bytes.len() + )); + } + + let mut key = [0u8; 32]; + key.copy_from_slice(&key_bytes); + Ok(Self::new(&key)) + } + + /// Create encryption service from environment variable + pub fn from_env() -> Result { + let key = env::var(ENCRYPTION_KEY_ENV).map_err(|_| { + anyhow!( + "Encryption key not set. Set {} environment variable with a base64-encoded 32-byte key", + ENCRYPTION_KEY_ENV + ) + })?; + Self::from_base64_key(&key) + } + + /// Get or initialize the global encryption service + pub fn global() -> Result<&'static Self> { + if let Some(service) = ENCRYPTION_SERVICE.get() { + return Ok(service); + } + + let service = Self::from_env()?; + Ok(ENCRYPTION_SERVICE.get_or_init(|| service)) + } + + /// Encrypt data using AES-256-GCM + /// + /// Returns the nonce (12 bytes) prepended to the ciphertext + pub fn encrypt(&self, plaintext: &[u8]) -> Result> { + // Generate a random 96-bit (12 byte) nonce + let mut nonce_bytes = [0u8; 12]; + rand::thread_rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + // Encrypt the plaintext + let ciphertext = self + .cipher + .encrypt(nonce, plaintext) + .map_err(|e| anyhow!("Encryption failed: {}", e))?; + + // Prepend nonce to ciphertext + let mut result = nonce_bytes.to_vec(); + result.extend(ciphertext); + Ok(result) + } + + /// Encrypt a string and return the encrypted data + pub fn encrypt_string(&self, plaintext: &str) -> Result> { + self.encrypt(plaintext.as_bytes()) + } + + /// Encrypt a JSON value and return the encrypted data + pub fn encrypt_json(&self, value: &T) -> Result> { + let json = + serde_json::to_string(value).map_err(|e| anyhow!("Failed to serialize JSON: {}", e))?; + self.encrypt(json.as_bytes()) + } + + /// Decrypt data encrypted with AES-256-GCM + /// + /// Expects the nonce (12 bytes) to be prepended to the ciphertext + pub fn decrypt(&self, data: &[u8]) -> Result> { + if data.len() < 12 { + return Err(anyhow!( + "Invalid encrypted data: too short (minimum 12 bytes for nonce)" + )); + } + + let (nonce_bytes, ciphertext) = data.split_at(12); + let nonce = Nonce::from_slice(nonce_bytes); + + self.cipher + .decrypt(nonce, ciphertext) + .map_err(|_| anyhow!("Decryption failed: invalid key or corrupted data")) + } + + /// Decrypt to a UTF-8 string + pub fn decrypt_string(&self, data: &[u8]) -> Result { + let plaintext = self.decrypt(data)?; + String::from_utf8(plaintext) + .map_err(|e| anyhow!("Decrypted data is not valid UTF-8: {}", e)) + } + + /// Decrypt to a JSON value + pub fn decrypt_json(&self, data: &[u8]) -> Result { + let plaintext = self.decrypt(data)?; + serde_json::from_slice(&plaintext) + .map_err(|e| anyhow!("Failed to parse decrypted JSON: {}", e)) + } + + /// Generate a new random encryption key (32 bytes) + pub fn generate_key() -> [u8; 32] { + let mut key = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut key); + key + } + + /// Generate a new random encryption key and encode as base64 + pub fn generate_key_base64() -> String { + BASE64.encode(Self::generate_key()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_key() -> [u8; 32] { + // Fixed test key for reproducible tests + [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, + 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, + 0x1c, 0x1d, 0x1e, 0x1f, + ] + } + + #[test] + fn test_encrypt_decrypt_roundtrip() { + let encryption = CredentialEncryption::new(&test_key()); + let plaintext = b"Hello, World!"; + + let encrypted = encryption.encrypt(plaintext).unwrap(); + let decrypted = encryption.decrypt(&encrypted).unwrap(); + + assert_eq!(plaintext.to_vec(), decrypted); + } + + #[test] + fn test_encrypt_decrypt_string_roundtrip() { + let encryption = CredentialEncryption::new(&test_key()); + let plaintext = "My secret API key: abc123"; + + let encrypted = encryption.encrypt_string(plaintext).unwrap(); + let decrypted = encryption.decrypt_string(&encrypted).unwrap(); + + assert_eq!(plaintext, decrypted); + } + + #[test] + fn test_encrypt_decrypt_json_roundtrip() { + #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)] + struct Credentials { + api_key: String, + secret: String, + } + + let encryption = CredentialEncryption::new(&test_key()); + let credentials = Credentials { + api_key: "my-api-key".to_string(), + secret: "my-secret".to_string(), + }; + + let encrypted = encryption.encrypt_json(&credentials).unwrap(); + let decrypted: Credentials = encryption.decrypt_json(&encrypted).unwrap(); + + assert_eq!(credentials, decrypted); + } + + #[test] + fn test_encrypted_data_includes_nonce() { + let encryption = CredentialEncryption::new(&test_key()); + let plaintext = b"Test data"; + + let encrypted = encryption.encrypt(plaintext).unwrap(); + + // Encrypted data should be at least 12 (nonce) + 16 (auth tag) + plaintext length + assert!(encrypted.len() >= 12 + 16 + plaintext.len()); + } + + #[test] + fn test_different_encryptions_produce_different_ciphertext() { + let encryption = CredentialEncryption::new(&test_key()); + let plaintext = b"Test data"; + + let encrypted1 = encryption.encrypt(plaintext).unwrap(); + let encrypted2 = encryption.encrypt(plaintext).unwrap(); + + // Due to random nonce, same plaintext produces different ciphertext + assert_ne!(encrypted1, encrypted2); + + // But both should decrypt to the same plaintext + let decrypted1 = encryption.decrypt(&encrypted1).unwrap(); + let decrypted2 = encryption.decrypt(&encrypted2).unwrap(); + assert_eq!(decrypted1, decrypted2); + } + + #[test] + fn test_decrypt_fails_with_wrong_key() { + let encryption1 = CredentialEncryption::new(&test_key()); + let mut wrong_key = test_key(); + wrong_key[0] ^= 0xFF; // Flip one byte + let encryption2 = CredentialEncryption::new(&wrong_key); + + let encrypted = encryption1.encrypt(b"Secret data").unwrap(); + let result = encryption2.decrypt(&encrypted); + + assert!(result.is_err()); + } + + #[test] + fn test_decrypt_fails_with_corrupted_data() { + let encryption = CredentialEncryption::new(&test_key()); + let mut encrypted = encryption.encrypt(b"Secret data").unwrap(); + + // Corrupt the ciphertext + if let Some(byte) = encrypted.last_mut() { + *byte ^= 0xFF; + } + + let result = encryption.decrypt(&encrypted); + assert!(result.is_err()); + } + + #[test] + fn test_decrypt_fails_with_short_data() { + let encryption = CredentialEncryption::new(&test_key()); + + // Data shorter than nonce (12 bytes) + let result = encryption.decrypt(&[0u8; 10]); + assert!(result.is_err()); + } + + #[test] + fn test_from_base64_key() { + let key = test_key(); + let base64_key = BASE64.encode(key); + + let encryption = CredentialEncryption::from_base64_key(&base64_key).unwrap(); + let plaintext = b"Test data"; + + let encrypted = encryption.encrypt(plaintext).unwrap(); + let decrypted = encryption.decrypt(&encrypted).unwrap(); + + assert_eq!(plaintext.to_vec(), decrypted); + } + + #[test] + fn test_from_base64_key_invalid_length() { + let short_key = BASE64.encode([0u8; 16]); // 16 bytes instead of 32 + let result = CredentialEncryption::from_base64_key(&short_key); + assert!(result.is_err()); + } + + #[test] + fn test_generate_key() { + let key1 = CredentialEncryption::generate_key(); + let key2 = CredentialEncryption::generate_key(); + + // Keys should be different (with overwhelming probability) + assert_ne!(key1, key2); + assert_eq!(key1.len(), 32); + } + + #[test] + fn test_generate_key_base64() { + let base64_key = CredentialEncryption::generate_key_base64(); + let decoded = BASE64.decode(&base64_key).unwrap(); + assert_eq!(decoded.len(), 32); + } +} diff --git a/src/services/plugin/handle.rs b/src/services/plugin/handle.rs new file mode 100644 index 00000000..e8f734eb --- /dev/null +++ b/src/services/plugin/handle.rs @@ -0,0 +1,526 @@ +//! Plugin Handle - Lifecycle Management +//! +//! This module provides the `PluginHandle` which manages a single plugin's lifecycle, +//! including initialization, request handling, health tracking, and shutdown. +//! +//! Note: Some methods and error variants are designed for the complete plugin API +//! but may not be called from external code yet. + +// Allow dead code for plugin API methods and error variants that are part of the +// complete API surface but not yet called from external code. +#![allow(dead_code)] + +use std::sync::Arc; +use std::time::Duration; + +use serde::de::DeserializeOwned; +use serde::Serialize; +use serde_json::Value; +use tokio::sync::RwLock; +use tracing::{debug, error, info, warn}; + +use super::health::{HealthState, HealthTracker}; +use super::process::{PluginProcess, PluginProcessConfig, ProcessError}; +use super::protocol::{ + methods, InitializeParams, MetadataGetParams, MetadataMatchParams, MetadataSearchParams, + MetadataSearchResponse, PluginBookMetadata, PluginManifest, PluginSeriesMetadata, SearchResult, +}; +use super::rpc::{RpcClient, RpcError}; +use super::secrets::SecretValue; + +/// Error type for plugin handle operations +#[derive(Debug, thiserror::Error)] +pub enum PluginError { + #[error("Plugin process error: {0}")] + Process(#[from] ProcessError), + + #[error("Plugin RPC error: {0}")] + Rpc(#[from] RpcError), + + #[error("Plugin not initialized")] + NotInitialized, + + #[error("Plugin is disabled: {reason}")] + Disabled { reason: String }, + + #[error("Plugin health check failed: {0}")] + HealthCheckFailed(String), + + #[error("Plugin spawn failed: {0}")] + SpawnFailed(String), + + #[error("Invalid manifest: {0}")] + InvalidManifest(String), +} + +/// Configuration for a plugin handle +/// +/// Note: The `credentials` field uses `SecretValue` which implements `Debug` +/// to show `[REDACTED]` instead of actual credential values, preventing +/// accidental exposure in logs. +#[derive(Clone)] +pub struct PluginConfig { + /// Process configuration + pub process: PluginProcessConfig, + /// Request timeout + pub request_timeout: Duration, + /// Shutdown timeout + pub shutdown_timeout: Duration, + /// Maximum consecutive failures before disabling + pub max_failures: u32, + /// Initial configuration to pass to plugin + pub config: Option, + /// Credentials to pass to plugin (via init message) + /// Uses SecretValue to prevent logging of sensitive data + pub credentials: Option, +} + +impl std::fmt::Debug for PluginConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PluginConfig") + .field("process", &self.process) + .field("request_timeout", &self.request_timeout) + .field("shutdown_timeout", &self.shutdown_timeout) + .field("max_failures", &self.max_failures) + .field("config", &self.config) + .field("credentials", &self.credentials) // SecretValue shows [REDACTED] + .finish() + } +} + +impl Default for PluginConfig { + fn default() -> Self { + Self { + process: PluginProcessConfig::new("echo"), + request_timeout: Duration::from_secs(30), + shutdown_timeout: Duration::from_secs(5), + max_failures: 3, + config: None, + credentials: None, + } + } +} + +/// State of the plugin handle +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PluginState { + /// Not yet started + Idle, + /// Process starting + Starting, + /// Process running and initialized + Running, + /// Being shut down + ShuttingDown, + /// Stopped (either gracefully or due to error) + Stopped, + /// Disabled due to failures + Disabled { reason: String }, +} + +/// Handle for managing a single plugin's lifecycle +pub struct PluginHandle { + /// Plugin configuration + config: PluginConfig, + /// Plugin state + state: Arc>, + /// RPC client (if running) + client: Arc>>, + /// Cached manifest (after initialization) + manifest: Arc>>, + /// Health tracker + health: Arc, +} + +impl PluginHandle { + /// Create a new plugin handle with the given configuration + pub fn new(config: PluginConfig) -> Self { + Self { + health: Arc::new(HealthTracker::new(config.max_failures)), + config, + state: Arc::new(RwLock::new(PluginState::Idle)), + client: Arc::new(RwLock::new(None)), + manifest: Arc::new(RwLock::new(None)), + } + } + + /// Get the current plugin state + pub async fn state(&self) -> PluginState { + self.state.read().await.clone() + } + + /// Get the cached manifest (if initialized) + pub async fn manifest(&self) -> Option { + self.manifest.read().await.clone() + } + + /// Get the health state + pub async fn health_state(&self) -> HealthState { + self.health.state().await + } + + /// Check if the plugin is currently running + pub async fn is_running(&self) -> bool { + matches!(*self.state.read().await, PluginState::Running) + } + + /// Check if the plugin is disabled + pub async fn is_disabled(&self) -> bool { + matches!(*self.state.read().await, PluginState::Disabled { .. }) + } + + /// Spawn the plugin process and initialize it + pub async fn start(&self) -> Result { + // Check if already running + { + let state = self.state.read().await; + match &*state { + PluginState::Running => { + if let Some(manifest) = self.manifest.read().await.clone() { + return Ok(manifest); + } + } + PluginState::Disabled { reason } => { + return Err(PluginError::Disabled { + reason: reason.clone(), + }); + } + _ => {} + } + } + + // Update state to starting + { + let mut state = self.state.write().await; + *state = PluginState::Starting; + } + + debug!("Starting plugin process"); + + // Spawn the process + let process = match PluginProcess::spawn(&self.config.process).await { + Ok(p) => p, + Err(e) => { + let mut state = self.state.write().await; + *state = PluginState::Stopped; + return Err(PluginError::SpawnFailed(e.to_string())); + } + }; + + // Create RPC client + let mut client = RpcClient::new(process, self.config.request_timeout); + + // Initialize the plugin + // Convert SecretValue to Value for the init message + let init_params = InitializeParams { + config: self.config.config.clone(), + credentials: self.config.credentials.as_ref().map(|s| s.inner().clone()), + }; + + let manifest: PluginManifest = match client.call(methods::INITIALIZE, init_params).await { + Ok(m) => m, + Err(e) => { + error!("Plugin initialization failed: {}", e); + let _ = client.shutdown(self.config.shutdown_timeout).await; + let mut state = self.state.write().await; + *state = PluginState::Stopped; + self.health.record_failure().await; + return Err(PluginError::Rpc(e)); + } + }; + + info!( + name = %manifest.name, + version = %manifest.version, + "Plugin initialized successfully" + ); + + // Store the client and manifest + { + let mut client_lock = self.client.write().await; + *client_lock = Some(client); + } + { + let mut manifest_lock = self.manifest.write().await; + *manifest_lock = Some(manifest.clone()); + } + { + let mut state = self.state.write().await; + *state = PluginState::Running; + } + + self.health.record_success().await; + Ok(manifest) + } + + /// Stop the plugin gracefully + pub async fn stop(&self) -> Result<(), PluginError> { + let current_state = self.state.read().await.clone(); + + if !matches!(current_state, PluginState::Running) { + debug!("Plugin not running, nothing to stop"); + return Ok(()); + } + + // Update state + { + let mut state = self.state.write().await; + *state = PluginState::ShuttingDown; + } + + debug!("Stopping plugin"); + + // Send shutdown message and close client + let mut client_opt = self.client.write().await; + if let Some(mut client) = client_opt.take() { + // Try to send shutdown notification + if let Err(e) = client.call_no_params::(methods::SHUTDOWN).await { + warn!("Plugin shutdown request failed: {}", e); + } + + // Wait for process to exit + match client.shutdown(self.config.shutdown_timeout).await { + Ok(code) => { + info!("Plugin process exited with code {}", code); + } + Err(e) => { + warn!("Plugin shutdown error: {}", e); + } + } + } + + // Update state + { + let mut state = self.state.write().await; + *state = PluginState::Stopped; + } + + Ok(()) + } + + /// Restart the plugin + pub async fn restart(&self) -> Result { + self.stop().await?; + self.start().await + } + + /// Send a ping to check if the plugin is responsive + pub async fn ping(&self) -> Result<(), PluginError> { + self.ensure_running().await?; + let client_guard = self.client.read().await; + let client = client_guard.as_ref().ok_or(PluginError::NotInitialized)?; + let _: String = client.call_no_params(methods::PING).await?; + self.health.record_success().await; + Ok(()) + } + + /// Search for series metadata + pub async fn search_series( + &self, + params: MetadataSearchParams, + ) -> Result { + self.ensure_running().await?; + let client_guard = self.client.read().await; + let client = client_guard.as_ref().ok_or(PluginError::NotInitialized)?; + match client.call(methods::METADATA_SERIES_SEARCH, params).await { + Ok(response) => { + self.health.record_success().await; + Ok(response) + } + Err(e) => { + self.health.record_failure().await; + self.check_and_disable().await; + Err(PluginError::Rpc(e)) + } + } + } + + /// Get series metadata by external ID + pub async fn get_series_metadata( + &self, + params: MetadataGetParams, + ) -> Result { + self.ensure_running().await?; + let client_guard = self.client.read().await; + let client = client_guard.as_ref().ok_or(PluginError::NotInitialized)?; + match client.call(methods::METADATA_SERIES_GET, params).await { + Ok(response) => { + self.health.record_success().await; + Ok(response) + } + Err(e) => { + self.health.record_failure().await; + self.check_and_disable().await; + Err(PluginError::Rpc(e)) + } + } + } + + /// Get book metadata by external ID (future use) + #[allow(dead_code)] + pub async fn get_book_metadata( + &self, + params: MetadataGetParams, + ) -> Result { + self.ensure_running().await?; + let client_guard = self.client.read().await; + let client = client_guard.as_ref().ok_or(PluginError::NotInitialized)?; + // TODO: Change to METADATA_BOOK_GET when book metadata is implemented + match client.call(methods::METADATA_SERIES_GET, params).await { + Ok(response) => { + self.health.record_success().await; + Ok(response) + } + Err(e) => { + self.health.record_failure().await; + self.check_and_disable().await; + Err(PluginError::Rpc(e)) + } + } + } + + /// Find best match for a series title + pub async fn match_series( + &self, + params: MetadataMatchParams, + ) -> Result, PluginError> { + self.ensure_running().await?; + let client_guard = self.client.read().await; + let client = client_guard.as_ref().ok_or(PluginError::NotInitialized)?; + match client.call(methods::METADATA_SERIES_MATCH, params).await { + Ok(response) => { + self.health.record_success().await; + Ok(response) + } + Err(e) => { + self.health.record_failure().await; + self.check_and_disable().await; + Err(PluginError::Rpc(e)) + } + } + } + + /// Call an arbitrary method on the plugin + pub async fn call_method(&self, method: &str, params: P) -> Result + where + P: Serialize, + R: DeserializeOwned, + { + self.ensure_running().await?; + let client_guard = self.client.read().await; + let client = client_guard.as_ref().ok_or(PluginError::NotInitialized)?; + match client.call(method, params).await { + Ok(response) => { + self.health.record_success().await; + Ok(response) + } + Err(e) => { + self.health.record_failure().await; + self.check_and_disable().await; + Err(PluginError::Rpc(e)) + } + } + } + + /// Re-enable a disabled plugin + pub async fn enable(&self) -> Result<(), PluginError> { + let current_state = self.state.read().await.clone(); + + if let PluginState::Disabled { reason: _ } = current_state { + self.health.reset().await; + { + let mut state = self.state.write().await; + *state = PluginState::Idle; + } + info!("Plugin re-enabled"); + Ok(()) + } else { + Ok(()) // Already enabled + } + } + + /// Ensure the plugin is in a running state + async fn ensure_running(&self) -> Result<(), PluginError> { + let state = self.state.read().await.clone(); + match state { + PluginState::Running => Ok(()), + PluginState::Disabled { reason } => Err(PluginError::Disabled { reason }), + _ => Err(PluginError::NotInitialized), + } + } + + /// Check if the plugin should be disabled due to failures + async fn check_and_disable(&self) { + if self.health.should_disable().await { + let mut state = self.state.write().await; + if matches!(*state, PluginState::Running) { + let reason = format!( + "Disabled after {} consecutive failures", + self.config.max_failures + ); + warn!("{}", reason); + *state = PluginState::Disabled { reason }; + } + } + } +} + +// Note: We need a custom impl because RpcClient contains tokio tasks +impl Drop for PluginHandle { + fn drop(&mut self) { + // The RpcClient will clean up its tasks when dropped + // The process will be killed due to kill_on_drop(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_plugin_config_default() { + let config = PluginConfig::default(); + assert_eq!(config.request_timeout, Duration::from_secs(30)); + assert_eq!(config.shutdown_timeout, Duration::from_secs(5)); + assert_eq!(config.max_failures, 3); + } + + #[test] + fn test_plugin_state_eq() { + assert_eq!(PluginState::Idle, PluginState::Idle); + assert_eq!(PluginState::Running, PluginState::Running); + assert_ne!(PluginState::Idle, PluginState::Running); + + let disabled1 = PluginState::Disabled { + reason: "test".to_string(), + }; + let disabled2 = PluginState::Disabled { + reason: "test".to_string(), + }; + assert_eq!(disabled1, disabled2); + } + + #[tokio::test] + async fn test_plugin_handle_initial_state() { + let config = PluginConfig::default(); + let handle = PluginHandle::new(config); + + assert_eq!(handle.state().await, PluginState::Idle); + assert!(!handle.is_running().await); + assert!(!handle.is_disabled().await); + assert!(handle.manifest().await.is_none()); + } + + #[tokio::test] + async fn test_plugin_handle_enable_when_not_disabled() { + let config = PluginConfig::default(); + let handle = PluginHandle::new(config); + + // Should be a no-op when not disabled + handle.enable().await.unwrap(); + assert_eq!(handle.state().await, PluginState::Idle); + } + + // Integration tests would require a mock plugin process + // See tests/integration/plugin_handle.rs for full integration tests +} diff --git a/src/services/plugin/health.rs b/src/services/plugin/health.rs new file mode 100644 index 00000000..2726d55c --- /dev/null +++ b/src/services/plugin/health.rs @@ -0,0 +1,378 @@ +//! Health Monitoring for Plugins +//! +//! This module provides health tracking and monitoring for plugins, +//! including failure counting and auto-disable logic. +//! +//! Note: This module provides complete health monitoring infrastructure. +//! Some types and methods may not be called from external code yet but are +//! part of the complete API for plugin health management. + +// Allow dead code for health monitoring infrastructure that is part of the +// complete API surface but not yet fully integrated. +#![allow(dead_code)] + +use chrono::{DateTime, Utc}; +use std::sync::atomic::{AtomicU32, Ordering}; +use tokio::sync::RwLock; + +/// Health status of a plugin +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HealthStatus { + /// Plugin is healthy + Healthy, + /// Plugin is degraded (some failures but still operational) + Degraded, + /// Plugin is unhealthy (at or near failure threshold) + Unhealthy, + /// Plugin health is unknown (not yet checked) + Unknown, + /// Plugin is disabled due to failures + Disabled, +} + +impl HealthStatus { + pub fn as_str(&self) -> &str { + match self { + HealthStatus::Healthy => "healthy", + HealthStatus::Degraded => "degraded", + HealthStatus::Unhealthy => "unhealthy", + HealthStatus::Unknown => "unknown", + HealthStatus::Disabled => "disabled", + } + } +} + +impl std::fmt::Display for HealthStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// Current health state of a plugin +#[derive(Debug, Clone)] +pub struct HealthState { + /// Current health status + pub status: HealthStatus, + /// Number of consecutive failures + pub consecutive_failures: u32, + /// Total failure count (lifetime) + pub total_failures: u32, + /// Total success count (lifetime) + pub total_successes: u32, + /// Last successful operation + pub last_success_at: Option>, + /// Last failed operation + pub last_failure_at: Option>, + /// Reason for current status (if applicable) + pub reason: Option, +} + +impl Default for HealthState { + fn default() -> Self { + Self { + status: HealthStatus::Unknown, + consecutive_failures: 0, + total_failures: 0, + total_successes: 0, + last_success_at: None, + last_failure_at: None, + reason: None, + } + } +} + +/// Tracks health state for a plugin +pub struct HealthTracker { + /// Maximum consecutive failures before disabling + max_failures: u32, + /// Number of consecutive failures + consecutive_failures: AtomicU32, + /// Total failure count + total_failures: AtomicU32, + /// Total success count + total_successes: AtomicU32, + /// Last success time + last_success_at: RwLock>>, + /// Last failure time + last_failure_at: RwLock>>, + /// Whether the plugin has been disabled + disabled: RwLock, + /// Reason for being disabled + disabled_reason: RwLock>, +} + +impl HealthTracker { + /// Create a new health tracker + pub fn new(max_failures: u32) -> Self { + Self { + max_failures, + consecutive_failures: AtomicU32::new(0), + total_failures: AtomicU32::new(0), + total_successes: AtomicU32::new(0), + last_success_at: RwLock::new(None), + last_failure_at: RwLock::new(None), + disabled: RwLock::new(false), + disabled_reason: RwLock::new(None), + } + } + + /// Record a successful operation + pub async fn record_success(&self) { + self.consecutive_failures.store(0, Ordering::SeqCst); + self.total_successes.fetch_add(1, Ordering::SeqCst); + *self.last_success_at.write().await = Some(Utc::now()); + } + + /// Record a failed operation + pub async fn record_failure(&self) { + self.consecutive_failures.fetch_add(1, Ordering::SeqCst); + self.total_failures.fetch_add(1, Ordering::SeqCst); + *self.last_failure_at.write().await = Some(Utc::now()); + } + + /// Check if the plugin should be disabled due to failures + pub async fn should_disable(&self) -> bool { + let failures = self.consecutive_failures.load(Ordering::SeqCst); + failures >= self.max_failures + } + + /// Mark the plugin as disabled + pub async fn mark_disabled(&self, reason: impl Into) { + *self.disabled.write().await = true; + *self.disabled_reason.write().await = Some(reason.into()); + } + + /// Check if the plugin is disabled + pub async fn is_disabled(&self) -> bool { + *self.disabled.read().await + } + + /// Reset the health tracker (for re-enabling) + pub async fn reset(&self) { + self.consecutive_failures.store(0, Ordering::SeqCst); + *self.disabled.write().await = false; + *self.disabled_reason.write().await = None; + } + + /// Get the current health state + pub async fn state(&self) -> HealthState { + let consecutive_failures = self.consecutive_failures.load(Ordering::SeqCst); + let total_failures = self.total_failures.load(Ordering::SeqCst); + let total_successes = self.total_successes.load(Ordering::SeqCst); + let last_success_at = *self.last_success_at.read().await; + let last_failure_at = *self.last_failure_at.read().await; + let is_disabled = *self.disabled.read().await; + let disabled_reason = self.disabled_reason.read().await.clone(); + + let status = if is_disabled { + HealthStatus::Disabled + } else if total_successes == 0 && total_failures == 0 { + HealthStatus::Unknown + } else if consecutive_failures >= self.max_failures { + HealthStatus::Unhealthy + } else if consecutive_failures > 0 { + HealthStatus::Degraded + } else { + HealthStatus::Healthy + }; + + HealthState { + status, + consecutive_failures, + total_failures, + total_successes, + last_success_at, + last_failure_at, + reason: disabled_reason, + } + } + + /// Get the current health status + pub async fn status(&self) -> HealthStatus { + self.state().await.status + } +} + +/// Monitor for managing health checks across multiple plugins +pub struct HealthMonitor { + /// Check interval + check_interval: std::time::Duration, + /// Whether monitoring is active + active: RwLock, +} + +impl HealthMonitor { + /// Create a new health monitor + pub fn new(check_interval: std::time::Duration) -> Self { + Self { + check_interval, + active: RwLock::new(false), + } + } + + /// Get the check interval + pub fn check_interval(&self) -> std::time::Duration { + self.check_interval + } + + /// Check if monitoring is active + pub async fn is_active(&self) -> bool { + *self.active.read().await + } + + /// Start monitoring (placeholder - actual implementation in Phase 2) + pub async fn start(&self) { + *self.active.write().await = true; + } + + /// Stop monitoring + pub async fn stop(&self) { + *self.active.write().await = false; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_health_status_as_str() { + assert_eq!(HealthStatus::Healthy.as_str(), "healthy"); + assert_eq!(HealthStatus::Degraded.as_str(), "degraded"); + assert_eq!(HealthStatus::Unhealthy.as_str(), "unhealthy"); + assert_eq!(HealthStatus::Unknown.as_str(), "unknown"); + assert_eq!(HealthStatus::Disabled.as_str(), "disabled"); + } + + #[test] + fn test_health_status_display() { + assert_eq!(format!("{}", HealthStatus::Healthy), "healthy"); + assert_eq!(format!("{}", HealthStatus::Disabled), "disabled"); + } + + #[test] + fn test_health_state_default() { + let state = HealthState::default(); + assert_eq!(state.status, HealthStatus::Unknown); + assert_eq!(state.consecutive_failures, 0); + assert_eq!(state.total_failures, 0); + assert_eq!(state.total_successes, 0); + assert!(state.last_success_at.is_none()); + assert!(state.last_failure_at.is_none()); + assert!(state.reason.is_none()); + } + + #[tokio::test] + async fn test_health_tracker_initial_state() { + let tracker = HealthTracker::new(3); + let state = tracker.state().await; + + assert_eq!(state.status, HealthStatus::Unknown); + assert_eq!(state.consecutive_failures, 0); + assert_eq!(state.total_failures, 0); + assert_eq!(state.total_successes, 0); + } + + #[tokio::test] + async fn test_health_tracker_record_success() { + let tracker = HealthTracker::new(3); + + tracker.record_success().await; + let state = tracker.state().await; + + assert_eq!(state.status, HealthStatus::Healthy); + assert_eq!(state.consecutive_failures, 0); + assert_eq!(state.total_successes, 1); + assert!(state.last_success_at.is_some()); + } + + #[tokio::test] + async fn test_health_tracker_record_failure() { + let tracker = HealthTracker::new(3); + + tracker.record_failure().await; + let state = tracker.state().await; + + assert_eq!(state.status, HealthStatus::Degraded); + assert_eq!(state.consecutive_failures, 1); + assert_eq!(state.total_failures, 1); + assert!(state.last_failure_at.is_some()); + } + + #[tokio::test] + async fn test_health_tracker_success_resets_failures() { + let tracker = HealthTracker::new(3); + + // Record some failures + tracker.record_failure().await; + tracker.record_failure().await; + assert_eq!(tracker.state().await.consecutive_failures, 2); + + // Success should reset consecutive failures + tracker.record_success().await; + assert_eq!(tracker.state().await.consecutive_failures, 0); + assert_eq!(tracker.state().await.total_failures, 2); // Total unchanged + } + + #[tokio::test] + async fn test_health_tracker_should_disable() { + let tracker = HealthTracker::new(3); + + // Not enough failures yet + tracker.record_failure().await; + tracker.record_failure().await; + assert!(!tracker.should_disable().await); + + // Third failure should trigger disable + tracker.record_failure().await; + assert!(tracker.should_disable().await); + assert_eq!(tracker.state().await.status, HealthStatus::Unhealthy); + } + + #[tokio::test] + async fn test_health_tracker_mark_disabled() { + let tracker = HealthTracker::new(3); + + tracker.mark_disabled("Too many failures").await; + let state = tracker.state().await; + + assert_eq!(state.status, HealthStatus::Disabled); + assert!(tracker.is_disabled().await); + assert_eq!(state.reason, Some("Too many failures".to_string())); + } + + #[tokio::test] + async fn test_health_tracker_reset() { + let tracker = HealthTracker::new(3); + + // Add some failures and disable + tracker.record_failure().await; + tracker.record_failure().await; + tracker.record_failure().await; + tracker.mark_disabled("Test").await; + + assert!(tracker.is_disabled().await); + + // Reset should clear disabled state + tracker.reset().await; + + assert!(!tracker.is_disabled().await); + assert_eq!(tracker.state().await.consecutive_failures, 0); + // Note: total_failures is NOT reset + assert_eq!(tracker.state().await.total_failures, 3); + } + + #[tokio::test] + async fn test_health_monitor_lifecycle() { + let monitor = HealthMonitor::new(std::time::Duration::from_secs(30)); + + assert!(!monitor.is_active().await); + assert_eq!(monitor.check_interval(), std::time::Duration::from_secs(30)); + + monitor.start().await; + assert!(monitor.is_active().await); + + monitor.stop().await; + assert!(!monitor.is_active().await); + } +} diff --git a/src/services/plugin/manager.rs b/src/services/plugin/manager.rs new file mode 100644 index 00000000..b2c18fab --- /dev/null +++ b/src/services/plugin/manager.rs @@ -0,0 +1,1229 @@ +//! Plugin Manager - Multi-Plugin Coordination +//! +//! This module provides the `PluginManager` which coordinates multiple plugins, +//! handling plugin lifecycle, database synchronization, and request routing. +//! +//! ## Responsibilities +//! +//! - Load plugin configurations from database +//! - Spawn and manage plugin processes (lazy loading) +//! - Route requests to appropriate plugins based on scope +//! - Synchronize health status with database +//! - Handle plugin enable/disable/restart operations +//! +//! ## Architecture +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────────────┐ +//! │ PluginManager │ +//! │ │ +//! │ plugins: HashMap │ +//! │ │ +//! │ ┌──────────────────────────────────────────────────────────────┐ │ +//! │ │ PluginEntry │ │ +//! │ │ db_config: plugins::Model (from database) │ │ +//! │ │ handle: Option (spawned process) │ │ +//! │ └──────────────────────────────────────────────────────────────┘ │ +//! │ │ +//! │ Methods: │ +//! │ - load_all() → Load plugins from DB │ +//! │ - get_or_spawn() → Lazy spawn plugin on first use │ +//! │ - by_scope() → Get plugins that support a scope │ +//! │ - shutdown_all() → Graceful shutdown of all plugins │ +//! └─────────────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! Note: This module provides complete plugin management infrastructure. +//! Some methods and error variants may not be called from external code yet +//! but are part of the complete API for plugin lifecycle management. + +// Allow dead code for plugin management infrastructure that is part of the +// complete API surface but not yet fully integrated. +#![allow(dead_code)] + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use sea_orm::DatabaseConnection; +use tokio::sync::{Mutex, RwLock}; +use tracing::{debug, info, warn}; +use uuid::Uuid; + +use crate::db::entities::plugins; +use crate::db::repositories::{FailureContext, PluginFailuresRepository, PluginsRepository}; +use crate::services::PluginMetricsService; + +use super::handle::{PluginConfig, PluginError, PluginHandle}; +use super::process::PluginProcessConfig; +use super::protocol::{ + MetadataGetParams, MetadataMatchParams, MetadataSearchParams, MetadataSearchResponse, + PluginScope, PluginSeriesMetadata, SearchResult, +}; +use super::secrets::SecretValue; + +/// Error type for plugin manager operations +#[derive(Debug, thiserror::Error)] +pub enum PluginManagerError { + #[error("Plugin not found: {0}")] + PluginNotFound(Uuid), + + #[error("Plugin not enabled: {0}")] + PluginNotEnabled(Uuid), + + #[error("Plugin error: {0}")] + Plugin(#[from] PluginError), + + #[error("Database error: {0}")] + Database(#[from] anyhow::Error), + + #[error("Encryption error: {0}")] + Encryption(String), + + #[error("No plugins available for scope: {0:?}")] + NoPluginsForScope(PluginScope), + + #[error("Rate limit exceeded for plugin {plugin_id}: {requests_per_minute} requests/minute")] + RateLimited { + plugin_id: Uuid, + requests_per_minute: i32, + }, +} + +/// Configuration for the plugin manager +#[derive(Debug, Clone)] +pub struct PluginManagerConfig { + /// Default request timeout for plugins + pub default_request_timeout: Duration, + /// Default shutdown timeout for plugins + pub default_shutdown_timeout: Duration, + /// Time window for counting failures (in seconds) + /// Failures outside this window are not counted for auto-disable + pub failure_window_seconds: i64, + /// Number of failures within the window to trigger auto-disable + pub failure_threshold: u32, + /// How long to keep failure records (in days) + pub failure_retention_days: i64, + /// Whether to auto-sync health status to database + pub auto_sync_health: bool, + /// Interval between health checks (0 = disabled) + pub health_check_interval: Duration, + /// TTL for the plugin cache before refreshing from database + /// This ensures multi-pod deployments eventually see plugin changes + pub cache_ttl: Duration, +} + +impl Default for PluginManagerConfig { + fn default() -> Self { + Self { + default_request_timeout: Duration::from_secs(30), + default_shutdown_timeout: Duration::from_secs(5), + failure_window_seconds: 3600, // 1 hour + failure_threshold: 3, + failure_retention_days: 30, + auto_sync_health: true, + health_check_interval: Duration::from_secs(60), // Check every minute + cache_ttl: Duration::from_secs(30), // Refresh from DB every 30 seconds + } + } +} + +/// Token bucket rate limiter for per-plugin rate limiting +/// +/// Uses atomic operations for thread-safe rate limiting without locks. +/// Tokens refill over time based on the configured rate. +#[derive(Debug)] +pub struct TokenBucketRateLimiter { + /// Current number of available tokens (scaled by 1000 for precision) + tokens: AtomicU32, + /// Last refill time as milliseconds since process start + last_refill_ms: AtomicU64, + /// Maximum tokens (bucket capacity) + capacity: u32, + /// Tokens to add per second (refill rate) + tokens_per_second: f64, + /// Start time for calculating elapsed milliseconds + start_instant: Instant, +} + +impl TokenBucketRateLimiter { + /// Create a new rate limiter with the given requests per minute limit + pub fn new(requests_per_minute: i32) -> Self { + let capacity = requests_per_minute as u32; + let tokens_per_second = requests_per_minute as f64 / 60.0; + + Self { + tokens: AtomicU32::new(capacity), + last_refill_ms: AtomicU64::new(0), + capacity, + tokens_per_second, + start_instant: Instant::now(), + } + } + + /// Try to acquire a token. Returns true if successful, false if rate limited. + pub fn try_acquire(&self) -> bool { + // Calculate elapsed time since start + let now_ms = self.start_instant.elapsed().as_millis() as u64; + + // Refill tokens based on elapsed time + let last_refill = self.last_refill_ms.load(Ordering::Acquire); + let elapsed_ms = now_ms.saturating_sub(last_refill); + + if elapsed_ms > 0 { + // Calculate tokens to add + let tokens_to_add = + (elapsed_ms as f64 / 1000.0 * self.tokens_per_second).floor() as u32; + + if tokens_to_add > 0 { + // Try to update last_refill time (CAS to handle concurrent updates) + let _ = self.last_refill_ms.compare_exchange( + last_refill, + now_ms, + Ordering::Release, + Ordering::Relaxed, + ); + + // Add tokens up to capacity + loop { + let current = self.tokens.load(Ordering::Acquire); + let new_tokens = (current + tokens_to_add).min(self.capacity); + if current == new_tokens { + break; + } + if self + .tokens + .compare_exchange(current, new_tokens, Ordering::Release, Ordering::Relaxed) + .is_ok() + { + break; + } + } + } + } + + // Try to consume a token + loop { + let current = self.tokens.load(Ordering::Acquire); + if current == 0 { + return false; + } + if self + .tokens + .compare_exchange(current, current - 1, Ordering::Release, Ordering::Relaxed) + .is_ok() + { + return true; + } + } + } + + /// Get the current number of available tokens + pub fn available_tokens(&self) -> u32 { + self.tokens.load(Ordering::Acquire) + } + + /// Get the bucket capacity + pub fn capacity(&self) -> u32 { + self.capacity + } +} + +/// Entry for a managed plugin +struct PluginEntry { + /// Plugin configuration from database + db_config: plugins::Model, + /// Plugin handle (lazily spawned) + handle: Option>, + /// Rate limiter (if rate limit is configured) + rate_limiter: Option, + /// Spawn mutex to prevent concurrent spawn operations for the same plugin. + /// This prevents a race condition where the write lock is released during + /// the async `is_running()` check, allowing duplicate processes to spawn. + spawn_mutex: Arc>, +} + +impl PluginEntry { + /// Create a new plugin entry from a database model + fn new(plugin: plugins::Model) -> Self { + let rate_limiter = plugin + .rate_limit_requests_per_minute + .filter(|&r| r > 0) + .map(TokenBucketRateLimiter::new); + + Self { + db_config: plugin, + handle: None, + rate_limiter, + spawn_mutex: Arc::new(Mutex::new(())), + } + } + + /// Update the plugin configuration and recreate the rate limiter if needed + fn update_config(&mut self, plugin: plugins::Model) { + // Check if rate limit changed + let old_rate = self.db_config.rate_limit_requests_per_minute; + let new_rate = plugin.rate_limit_requests_per_minute; + + if old_rate != new_rate { + self.rate_limiter = new_rate.filter(|&r| r > 0).map(TokenBucketRateLimiter::new); + } + + self.db_config = plugin; + } +} + +/// Manager for coordinating multiple plugins +pub struct PluginManager { + /// Database connection + db: Arc, + /// Manager configuration + config: PluginManagerConfig, + /// Managed plugins by ID + plugins: Arc>>, + /// When the plugin cache was last refreshed from database + /// Used for TTL-based cache invalidation in multi-pod deployments + cache_loaded_at: RwLock>, + /// Mutex to prevent thundering herd on cache refresh. + /// Only one task can refresh the cache at a time; others wait for it to complete. + cache_refresh_mutex: Mutex<()>, + /// Health check task handle + health_check_handle: RwLock>>, + /// Optional metrics service for recording plugin operation metrics + metrics_service: Option>, +} + +impl PluginManager { + /// Create a new plugin manager + pub fn new(db: Arc, config: PluginManagerConfig) -> Self { + Self { + db, + config, + plugins: Arc::new(RwLock::new(HashMap::new())), + cache_loaded_at: RwLock::new(None), + cache_refresh_mutex: Mutex::new(()), + health_check_handle: RwLock::new(None), + metrics_service: None, + } + } + + /// Create a new plugin manager with default configuration + pub fn with_defaults(db: Arc) -> Self { + Self::new(db, PluginManagerConfig::default()) + } + + /// Set the metrics service for recording plugin operation metrics + pub fn with_metrics_service(mut self, metrics_service: Arc) -> Self { + self.metrics_service = Some(metrics_service); + self + } + + /// Get a reference to the metrics service if configured + pub fn metrics_service(&self) -> Option<&Arc> { + self.metrics_service.as_ref() + } + + /// Load all enabled plugins from database + pub async fn load_all(&self) -> Result { + debug!("Loading enabled plugins from database..."); + let enabled_plugins = PluginsRepository::get_enabled(&self.db).await?; + let count = enabled_plugins.len(); + debug!("Found {} enabled plugins in database", count); + + let mut plugins = self.plugins.write().await; + + // Preserve existing handles - we don't want to kill running plugin processes + // Just update the db_config for existing entries and add new ones + let mut existing_handles: HashMap>> = HashMap::new(); + for (id, entry) in plugins.drain() { + existing_handles.insert(id, entry.handle); + } + + for plugin in enabled_plugins { + let id = plugin.id; + debug!("Loading plugin: {} ({})", plugin.name, id); + let mut entry = PluginEntry::new(plugin); + // Restore handle if we had one + if let Some(handle) = existing_handles.remove(&id) { + entry.handle = handle; + } + plugins.insert(id, entry); + } + + // Stop any handles for plugins that are no longer enabled + for (_id, handle) in existing_handles { + if let Some(h) = handle { + let _ = h.stop().await; + } + } + + // Update cache timestamp + *self.cache_loaded_at.write().await = Some(Instant::now()); + + info!("Loaded {} enabled plugins from database", count); + Ok(count) + } + + /// Check if the cache is stale and needs refreshing + fn is_cache_stale(&self, loaded_at: Option) -> bool { + match loaded_at { + None => true, // Never loaded + Some(loaded) => loaded.elapsed() > self.config.cache_ttl, + } + } + + /// Refresh the plugin cache from database if it's stale + /// + /// This is called automatically by `plugins_by_scope` and similar methods + /// to ensure multi-pod deployments eventually see plugin changes. + /// + /// Uses double-checked locking to prevent thundering herd: + /// 1. Quick check without lock (fast path for fresh cache) + /// 2. Acquire mutex and re-check (handles concurrent refresh attempts) + /// 3. Refresh only if still stale after acquiring mutex + async fn refresh_if_stale(&self) -> Result<(), PluginManagerError> { + // Fast path: check if cache is stale without acquiring the refresh mutex + let loaded_at = *self.cache_loaded_at.read().await; + if !self.is_cache_stale(loaded_at) { + return Ok(()); + } + + // Slow path: acquire the refresh mutex to prevent thundering herd + let _refresh_guard = self.cache_refresh_mutex.lock().await; + + // Re-check after acquiring mutex - another task may have refreshed while we waited + let loaded_at = *self.cache_loaded_at.read().await; + if self.is_cache_stale(loaded_at) { + debug!("Plugin cache is stale, refreshing from database"); + self.load_all().await?; + } else { + debug!("Plugin cache was refreshed by another task while waiting"); + } + + Ok(()) + } + + /// Reload a specific plugin's configuration from database + pub async fn reload(&self, plugin_id: Uuid) -> Result<(), PluginManagerError> { + debug!("Reloading plugin {} from database", plugin_id); + + let plugin = PluginsRepository::get_by_id(&self.db, plugin_id) + .await? + .ok_or(PluginManagerError::PluginNotFound(plugin_id))?; + + debug!( + "Found plugin {} (name={}, enabled={}, scopes={:?})", + plugin_id, plugin.name, plugin.enabled, plugin.scopes + ); + + let mut plugins = self.plugins.write().await; + + if plugin.enabled { + // If plugin exists and has a handle, stop it first + if let Some(entry) = plugins.get_mut(&plugin_id) { + debug!("Updating existing plugin entry for {}", plugin_id); + if let Some(handle) = entry.handle.take() { + let _ = handle.stop().await; + } + entry.update_config(plugin); + } else { + debug!("Inserting new plugin entry for {}", plugin_id); + plugins.insert(plugin_id, PluginEntry::new(plugin)); + } + debug!("Plugin manager now has {} plugins loaded", plugins.len()); + } else { + // Plugin is disabled, remove it from managed plugins + debug!("Plugin {} is disabled, removing from memory", plugin_id); + if let Some(entry) = plugins.remove(&plugin_id) { + if let Some(handle) = entry.handle { + let _ = handle.stop().await; + } + } + } + + Ok(()) + } + + /// Remove a plugin from memory without fetching from database + /// + /// Use this when a plugin has been deleted from the database and you need + /// to clean up the in-memory state. + pub async fn remove(&self, plugin_id: Uuid) { + let mut plugins = self.plugins.write().await; + if let Some(entry) = plugins.remove(&plugin_id) { + if let Some(handle) = entry.handle { + let _ = handle.stop().await; + } + debug!("Removed plugin {} from memory", plugin_id); + } + } + + /// Get or spawn a plugin, returning a handle for operations + /// + /// This method uses a per-plugin spawn mutex to prevent race conditions where + /// multiple concurrent callers could spawn duplicate plugin processes. The + /// pattern is: + /// 1. Check if handle exists and is running (fast path, read lock only) + /// 2. If not, acquire the spawn mutex to serialize spawn operations + /// 3. Re-check under mutex in case another caller spawned while we waited + /// 4. Spawn if still needed + pub async fn get_or_spawn( + &self, + plugin_id: Uuid, + ) -> Result, PluginManagerError> { + // Fast path: check with read lock if we already have a running handle + { + let plugins = self.plugins.read().await; + let entry = plugins + .get(&plugin_id) + .ok_or(PluginManagerError::PluginNotFound(plugin_id))?; + + if !entry.db_config.enabled { + return Err(PluginManagerError::PluginNotEnabled(plugin_id)); + } + + if let Some(ref handle) = entry.handle { + if handle.is_running().await { + return Ok(Arc::clone(handle)); + } + } + } + + // Slow path: need to potentially spawn the plugin. + // First, get the spawn mutex to serialize spawn operations for this plugin. + // This prevents the race condition where multiple callers could see + // "not running" and all try to spawn. + let spawn_mutex = { + let plugins = self.plugins.read().await; + let entry = plugins + .get(&plugin_id) + .ok_or(PluginManagerError::PluginNotFound(plugin_id))?; + Arc::clone(&entry.spawn_mutex) + }; + + // Hold the spawn mutex while we check again and potentially spawn. + // This ensures only one caller can spawn at a time. + let _spawn_guard = spawn_mutex.lock().await; + + // Re-check now that we hold the spawn mutex - another caller may have + // spawned while we were waiting for the mutex. + { + let plugins = self.plugins.read().await; + let entry = plugins + .get(&plugin_id) + .ok_or(PluginManagerError::PluginNotFound(plugin_id))?; + + if !entry.db_config.enabled { + return Err(PluginManagerError::PluginNotEnabled(plugin_id)); + } + + if let Some(ref handle) = entry.handle { + if handle.is_running().await { + return Ok(Arc::clone(handle)); + } + } + } + + // Now get write lock and spawn + let mut plugins = self.plugins.write().await; + + let entry = plugins + .get_mut(&plugin_id) + .ok_or(PluginManagerError::PluginNotFound(plugin_id))?; + + // Final check under write lock (in case of config change) + if !entry.db_config.enabled { + return Err(PluginManagerError::PluginNotEnabled(plugin_id)); + } + + // Need to spawn/initialize the plugin + let handle_config = self.create_plugin_config(&entry.db_config).await?; + let handle = PluginHandle::new(handle_config); + + // Start the plugin + match handle.start().await { + Ok(manifest) => { + // Serialize manifest for storage + let manifest_json = serde_json::to_value(&manifest).unwrap_or_default(); + + // Update manifest in database + let _ = PluginsRepository::update_manifest( + &self.db, + plugin_id, + Some(manifest_json.clone()), + ) + .await; + + // Update manifest in in-memory config so it's available immediately + // for plugin action queries (which check cached_manifest for capabilities) + entry.db_config.manifest = Some(manifest_json); + + // Record success + if self.config.auto_sync_health { + let _ = PluginsRepository::record_success(&self.db, plugin_id).await; + } + + let handle = Arc::new(handle); + // Store the handle for reuse and health checks + entry.handle = Some(Arc::clone(&handle)); + Ok(handle) + } + Err(e) => { + // Record failure using time-windowed tracking + if self.config.auto_sync_health { + self.record_failure_and_check_disable( + plugin_id, + &e.to_string(), + Some("INIT_ERROR"), + Some("initialize"), + ) + .await; + } + + Err(PluginManagerError::Plugin(e)) + } + } + } + + /// Get all plugins that support a specific scope + /// + /// This method automatically refreshes the cache from the database if it's stale, + /// ensuring multi-pod deployments eventually see plugin changes. + pub async fn plugins_by_scope(&self, scope: &PluginScope) -> Vec { + // Refresh cache if stale (ignore errors - use stale data if DB is unavailable) + if let Err(e) = self.refresh_if_stale().await { + warn!("Failed to refresh plugin cache: {}", e); + } + + let plugins = self.plugins.read().await; + plugins + .values() + .filter(|entry| entry.db_config.has_scope(scope)) + .map(|entry| entry.db_config.clone()) + .collect() + } + + /// Get all plugins that support a specific scope AND apply to a specific library + /// + /// This filters plugins by: + /// 1. Scope support + /// 2. Library filtering (empty library_ids = all libraries, or library must be in the list) + /// + /// This method automatically refreshes the cache from the database if it's stale, + /// ensuring multi-pod deployments eventually see plugin changes. + pub async fn plugins_by_scope_and_library( + &self, + scope: &PluginScope, + library_id: Uuid, + ) -> Vec { + // Refresh cache if stale (ignore errors - use stale data if DB is unavailable) + if let Err(e) = self.refresh_if_stale().await { + warn!("Failed to refresh plugin cache: {}", e); + } + + let plugins = self.plugins.read().await; + plugins + .values() + .filter(|entry| { + entry.db_config.has_scope(scope) && entry.db_config.applies_to_library(library_id) + }) + .map(|entry| entry.db_config.clone()) + .collect() + } + + /// Get a specific plugin's database configuration + pub async fn get_plugin(&self, plugin_id: Uuid) -> Option { + let plugins = self.plugins.read().await; + plugins.get(&plugin_id).map(|e| e.db_config.clone()) + } + + /// Get all managed plugin configurations + pub async fn all_plugins(&self) -> Vec { + let plugins = self.plugins.read().await; + plugins.values().map(|e| e.db_config.clone()).collect() + } + + /// Check rate limit for a plugin. Returns Ok(plugin_name) if allowed, Err if rate limited. + async fn check_rate_limit(&self, plugin_id: Uuid) -> Result { + let plugins = self.plugins.read().await; + if let Some(entry) = plugins.get(&plugin_id) { + if let Some(ref rate_limiter) = entry.rate_limiter { + if !rate_limiter.try_acquire() { + let rate = entry.db_config.rate_limit_requests_per_minute.unwrap_or(0); + let plugin_name = entry.db_config.name.clone(); + + // Record rate limit rejection in metrics + if let Some(ref metrics) = self.metrics_service { + metrics.record_rate_limit(plugin_id, &plugin_name).await; + } + + return Err(PluginManagerError::RateLimited { + plugin_id, + requests_per_minute: rate, + }); + } + } + Ok(entry.db_config.name.clone()) + } else { + Ok(String::new()) + } + } + + /// Search for series metadata using a specific plugin + pub async fn search_series( + &self, + plugin_id: Uuid, + params: MetadataSearchParams, + ) -> Result { + // Check rate limit before making the request + let plugin_name = self.check_rate_limit(plugin_id).await?; + + let start = Instant::now(); + let handle = self.get_or_spawn(plugin_id).await?; + let result = handle.search_series(params).await; + let duration_ms = start.elapsed().as_millis() as u64; + + match &result { + Ok(_) => { + // Update health status on success + if self.config.auto_sync_health { + let _ = PluginsRepository::record_success(&self.db, plugin_id).await; + } + + // Record success in metrics + if let Some(ref metrics) = self.metrics_service { + metrics + .record_success(plugin_id, &plugin_name, "search", duration_ms) + .await; + } + } + Err(e) => { + // Record failure in metrics + if let Some(ref metrics) = self.metrics_service { + let error_code = self.error_to_code(e); + metrics + .record_failure( + plugin_id, + &plugin_name, + "search", + duration_ms, + Some(error_code), + ) + .await; + } + } + } + + Ok(result?) + } + + /// Get series metadata using a specific plugin + pub async fn get_series_metadata( + &self, + plugin_id: Uuid, + params: MetadataGetParams, + ) -> Result { + // Check rate limit before making the request + let plugin_name = self.check_rate_limit(plugin_id).await?; + + let start = Instant::now(); + let handle = self.get_or_spawn(plugin_id).await?; + let result = handle.get_series_metadata(params).await; + let duration_ms = start.elapsed().as_millis() as u64; + + match &result { + Ok(_) => { + // Update health status on success + if self.config.auto_sync_health { + let _ = PluginsRepository::record_success(&self.db, plugin_id).await; + } + + // Record success in metrics + if let Some(ref metrics) = self.metrics_service { + metrics + .record_success(plugin_id, &plugin_name, "get_metadata", duration_ms) + .await; + } + } + Err(e) => { + // Record failure in metrics + if let Some(ref metrics) = self.metrics_service { + let error_code = self.error_to_code(e); + metrics + .record_failure( + plugin_id, + &plugin_name, + "get_metadata", + duration_ms, + Some(error_code), + ) + .await; + } + } + } + + Ok(result?) + } + + /// Find best series match using a specific plugin + pub async fn match_series( + &self, + plugin_id: Uuid, + params: MetadataMatchParams, + ) -> Result, PluginManagerError> { + // Check rate limit before making the request + let plugin_name = self.check_rate_limit(plugin_id).await?; + + let start = Instant::now(); + let handle = self.get_or_spawn(plugin_id).await?; + let result = handle.match_series(params).await; + let duration_ms = start.elapsed().as_millis() as u64; + + match &result { + Ok(_) => { + // Update health status on success + if self.config.auto_sync_health { + let _ = PluginsRepository::record_success(&self.db, plugin_id).await; + } + + // Record success in metrics + if let Some(ref metrics) = self.metrics_service { + metrics + .record_success(plugin_id, &plugin_name, "match", duration_ms) + .await; + } + } + Err(e) => { + // Record failure in metrics + if let Some(ref metrics) = self.metrics_service { + let error_code = self.error_to_code(e); + metrics + .record_failure( + plugin_id, + &plugin_name, + "match", + duration_ms, + Some(error_code), + ) + .await; + } + } + } + + Ok(result?) + } + + /// Ping a plugin to check health + pub async fn ping(&self, plugin_id: Uuid) -> Result<(), PluginManagerError> { + let handle = self.get_or_spawn(plugin_id).await?; + handle.ping().await?; + + if self.config.auto_sync_health { + let _ = PluginsRepository::record_success(&self.db, plugin_id).await; + } + + Ok(()) + } + + /// Test a plugin connection by spawning it and getting its manifest + /// + /// This is useful for admin testing of plugin configuration without + /// affecting the managed plugin state. + pub async fn test_plugin( + &self, + _db: &DatabaseConnection, + plugin: &plugins::Model, + ) -> Result { + // Create config for the test + let handle_config = self.create_plugin_config(plugin).await?; + let handle = PluginHandle::new(handle_config); + + // Start the plugin and get manifest + let manifest = handle.start().await?; + + // Stop the test instance + let _ = handle.stop().await; + + Ok(manifest) + } + + /// Shutdown a specific plugin + pub async fn stop_plugin(&self, plugin_id: Uuid) -> Result<(), PluginManagerError> { + let mut plugins = self.plugins.write().await; + + if let Some(entry) = plugins.get_mut(&plugin_id) { + if let Some(handle) = entry.handle.take() { + handle.stop().await?; + } + } + + Ok(()) + } + + /// Shutdown all plugins gracefully + pub async fn shutdown_all(&self) { + // Stop health checks first + self.stop_health_checks().await; + + let mut plugins = self.plugins.write().await; + + for (id, entry) in plugins.iter_mut() { + if let Some(handle) = entry.handle.take() { + debug!("Shutting down plugin {}", id); + if let Err(e) = handle.stop().await { + warn!("Failed to stop plugin {}: {}", id, e); + } + } + } + + plugins.clear(); + info!("All plugins shut down"); + } + + /// Start periodic health checks for all running plugins + pub async fn start_health_checks(self: &Arc) { + // Don't start if health checks are disabled + if self.config.health_check_interval.is_zero() { + debug!("Health checks disabled (interval is 0)"); + return; + } + + // Stop any existing health check task + self.stop_health_checks().await; + + let interval = self.config.health_check_interval; + let manager = Arc::clone(self); + + let handle = tokio::spawn(async move { + info!("Starting plugin health checks every {:?}", interval); + + loop { + tokio::time::sleep(interval).await; + + // Get list of plugin IDs that have active handles + let plugin_ids: Vec = { + let plugins = manager.plugins.read().await; + plugins + .iter() + .filter(|(_, entry)| entry.handle.is_some() && entry.db_config.enabled) + .map(|(id, _)| *id) + .collect() + }; + + if plugin_ids.is_empty() { + debug!("No active plugins to health check"); + continue; + } + + debug!("Running health checks for {} plugins", plugin_ids.len()); + + for plugin_id in plugin_ids { + match manager.ping(plugin_id).await { + Ok(()) => { + debug!("Plugin {} health check passed", plugin_id); + } + Err(e) => { + warn!("Plugin {} health check failed: {}", plugin_id, e); + // Failure is already recorded by ping() + } + } + } + } + }); + + *self.health_check_handle.write().await = Some(handle); + } + + /// Stop periodic health checks + pub async fn stop_health_checks(&self) { + let mut handle = self.health_check_handle.write().await; + if let Some(h) = handle.take() { + h.abort(); + info!("Stopped plugin health checks"); + } + } + + /// Check if health checks are running + pub async fn health_checks_running(&self) -> bool { + let handle = self.health_check_handle.read().await; + handle.as_ref().is_some_and(|h| !h.is_finished()) + } + + /// Record a plugin failure and check if it should be auto-disabled + /// + /// This uses time-windowed failure tracking instead of consecutive failure counts. + /// A plugin is auto-disabled if it has >= threshold failures within the time window. + /// + /// Returns true if the plugin was auto-disabled. + async fn record_failure_and_check_disable( + &self, + plugin_id: Uuid, + error_message: &str, + error_code: Option<&str>, + method: Option<&str>, + ) -> bool { + // Record the failure in the plugin_failures table + let failure_context = FailureContext { + error_code: error_code.map(|s| s.to_string()), + method: method.map(|s| s.to_string()), + request_id: None, + context: None, + request_summary: None, + }; + + if let Err(e) = PluginFailuresRepository::record_failure( + &self.db, + plugin_id, + error_message, + failure_context, + Some(self.config.failure_retention_days), + ) + .await + { + warn!("Failed to record plugin failure: {}", e); + } + + // Also update the plugins table for quick status display + let _ = PluginsRepository::record_failure(&self.db, plugin_id, Some(error_message)).await; + + // Check if we should auto-disable using time-windowed counting + match PluginFailuresRepository::count_failures_in_window( + &self.db, + plugin_id, + self.config.failure_window_seconds, + ) + .await + { + Ok(count) => { + if count >= self.config.failure_threshold as u64 { + let reason = format!( + "Disabled after {} failures in {} seconds", + count, self.config.failure_window_seconds + ); + let _ = PluginsRepository::auto_disable(&self.db, plugin_id, &reason).await; + warn!( + "Plugin {} auto-disabled: {} failures in window", + plugin_id, count + ); + return true; + } + } + Err(e) => { + warn!("Failed to count plugin failures: {}", e); + } + } + + false + } + + /// Create a PluginConfig from database model + async fn create_plugin_config( + &self, + plugin: &plugins::Model, + ) -> Result { + // Build process config + let mut process_config = PluginProcessConfig::new(&plugin.command); + process_config = process_config.args(plugin.args_vec()); + + // Add environment variables from config + for (key, value) in plugin.env_vec() { + process_config = process_config.env(&key, &value); + } + + if let Some(wd) = &plugin.working_directory { + process_config = process_config.working_directory(wd); + } + + // Handle credentials based on delivery method + // We use SecretValue to prevent credential exposure in logs + let mut credentials: Option = None; + + if plugin.has_credentials() { + let decrypted = PluginsRepository::get_credentials(&self.db, plugin.id) + .await? + .ok_or_else(|| { + PluginManagerError::Encryption("Failed to decrypt credentials".to_string()) + })?; + + match plugin.credential_delivery.as_str() { + "env" | "both" => { + // Add credentials as environment variables + if let Some(obj) = decrypted.as_object() { + for (key, value) in obj { + if let Some(v) = value.as_str() { + process_config = process_config.env(key.to_uppercase(), v); + } + } + } + } + _ => {} + } + + match plugin.credential_delivery.as_str() { + "init_message" | "both" => { + // Wrap in SecretValue to prevent logging + credentials = Some(SecretValue::new(decrypted)); + } + _ => {} + } + } + + Ok(PluginConfig { + process: process_config, + request_timeout: self.config.default_request_timeout, + shutdown_timeout: self.config.default_shutdown_timeout, + max_failures: self.config.failure_threshold, + config: Some(plugin.config.clone()), + credentials, + }) + } + + /// Convert a PluginError to an error code for metrics + fn error_to_code(&self, error: &PluginError) -> &'static str { + match error { + PluginError::Process(_) => "PROCESS_ERROR", + PluginError::Rpc(_) => "RPC_ERROR", + PluginError::NotInitialized => "NOT_INITIALIZED", + PluginError::Disabled { .. } => "DISABLED", + PluginError::HealthCheckFailed(_) => "HEALTH_CHECK_FAILED", + PluginError::SpawnFailed(_) => "SPAWN_FAILED", + PluginError::InvalidManifest(_) => "INVALID_MANIFEST", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_plugin_manager_config_default() { + let config = PluginManagerConfig::default(); + assert_eq!(config.default_request_timeout, Duration::from_secs(30)); + assert_eq!(config.default_shutdown_timeout, Duration::from_secs(5)); + assert_eq!(config.failure_window_seconds, 3600); // 1 hour + assert_eq!(config.failure_threshold, 3); + assert_eq!(config.failure_retention_days, 30); + assert!(config.auto_sync_health); + assert_eq!(config.health_check_interval, Duration::from_secs(60)); + assert_eq!(config.cache_ttl, Duration::from_secs(30)); + } + + #[test] + fn test_token_bucket_rate_limiter_basic() { + // 60 requests per minute = 1 per second + let limiter = TokenBucketRateLimiter::new(60); + + // Should start with full capacity + assert_eq!(limiter.available_tokens(), 60); + assert_eq!(limiter.capacity(), 60); + + // Should be able to acquire tokens + assert!(limiter.try_acquire()); + assert_eq!(limiter.available_tokens(), 59); + + // Consume more tokens + for _ in 0..59 { + assert!(limiter.try_acquire()); + } + + // Should now be rate limited + assert_eq!(limiter.available_tokens(), 0); + assert!(!limiter.try_acquire()); + } + + #[test] + fn test_token_bucket_rate_limiter_refill() { + // 600 requests per minute = 10 per second for faster testing + let limiter = TokenBucketRateLimiter::new(600); + + // Consume all tokens + for _ in 0..600 { + assert!(limiter.try_acquire()); + } + + // Should be rate limited + assert!(!limiter.try_acquire()); + + // Wait 100ms - should refill 1 token (600/60 = 10 per second, so ~1 in 100ms) + std::thread::sleep(std::time::Duration::from_millis(150)); + + // Should have at least 1 token now + assert!(limiter.try_acquire()); + } + + #[test] + fn test_token_bucket_rate_limiter_max_capacity() { + let limiter = TokenBucketRateLimiter::new(10); + + // Full capacity + assert_eq!(limiter.available_tokens(), 10); + + // Use 5 tokens + for _ in 0..5 { + limiter.try_acquire(); + } + assert_eq!(limiter.available_tokens(), 5); + + // Wait for refill (longer than needed to fully refill) + std::thread::sleep(std::time::Duration::from_millis(700)); + + // Tokens should be capped at capacity + assert!(limiter.available_tokens() <= 10); + } + + #[test] + fn test_token_bucket_concurrent_access() { + use std::sync::Arc; + use std::thread; + + let limiter = Arc::new(TokenBucketRateLimiter::new(100)); + let mut handles = vec![]; + + // Spawn 10 threads, each trying to acquire 15 tokens + for _ in 0..10 { + let limiter = Arc::clone(&limiter); + handles.push(thread::spawn(move || { + let mut acquired = 0; + for _ in 0..15 { + if limiter.try_acquire() { + acquired += 1; + } + } + acquired + })); + } + + let total_acquired: usize = handles.into_iter().map(|h| h.join().unwrap()).sum(); + + // Total acquired should be exactly 100 (the capacity) + assert_eq!(total_acquired, 100); + } + + #[test] + fn test_is_cache_stale() { + use std::sync::Arc; + + // Create a manager with a short TTL for testing + let db = Arc::new(sea_orm::DatabaseConnection::Disconnected); + let config = PluginManagerConfig { + cache_ttl: Duration::from_millis(100), + ..Default::default() + }; + let manager = PluginManager::new(db, config); + + // No loaded_at means stale + assert!(manager.is_cache_stale(None)); + + // Just loaded means fresh + assert!(!manager.is_cache_stale(Some(Instant::now()))); + + // Old timestamp means stale + let old = Instant::now() - Duration::from_millis(200); + assert!(manager.is_cache_stale(Some(old))); + } + + // Integration tests require a database connection + // See tests/integration/plugin_manager.rs for full tests +} diff --git a/src/services/plugin/mod.rs b/src/services/plugin/mod.rs new file mode 100644 index 00000000..99b4d4c5 --- /dev/null +++ b/src/services/plugin/mod.rs @@ -0,0 +1,93 @@ +//! Plugin System for External Metadata Providers +//! +//! This module implements an MCP-style plugin system that allows Codex to communicate +//! with external processes for metadata fetching. Plugins can be written in any language +//! and communicate via JSON-RPC 2.0 over stdio. +//! +//! ## Architecture +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────────────┐ +//! │ CODEX SERVER │ +//! ├─────────────────────────────────────────────────────────────────────┤ +//! │ ┌──────────────────────────────────────────────────────────────┐ │ +//! │ │ Plugin Manager │ │ +//! │ │ • Spawns plugin processes (command + args) │ │ +//! │ │ • Communicates via stdio/JSON-RPC │ │ +//! │ │ • Enforces RBAC permissions on writes │ │ +//! │ │ • Monitors health, restarts on failure │ │ +//! │ │ • Rate limits requests per plugin (token bucket) │ │ +//! │ └───────────────────────────┬──────────────────────────────────┘ │ +//! │ ┌───────────────┼───────────────┐ │ +//! │ ▼ ▼ ▼ │ +//! │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +//! │ │ Plugin │ │ Plugin │ │ Plugin │ │ +//! │ │ Process 1 │ │ Process 2 │ │ Process N │ │ +//! │ │ stdin/stdout│ │ stdin/stdout│ │ stdin/stdout│ │ +//! │ └─────────────┘ └─────────────┘ └─────────────┘ │ +//! └─────────────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! ## Plugin Process Ownership +//! +//! Plugins are spawned by whichever Codex process needs them: +//! +//! - **`codex serve`**: Spawns plugins for HTTP API requests (search, get metadata) +//! - **`codex worker`**: Spawns plugins for background tasks (auto-match during library scans) +//! +//! **Important considerations:** +//! +//! 1. **No shared state**: Each Codex process maintains its own `PluginManager` and plugin +//! instances. If you run multiple workers, each will spawn its own copy of plugins. +//! +//! 2. **Stateless plugins required**: Plugins MUST be stateless. Do not rely on in-memory +//! state between requests. Any persistent state should be stored externally (database, +//! file system, etc.). +//! +//! 3. **Rate limits are per-process**: Rate limiting is enforced per `PluginManager` instance. +//! Running 3 workers means 3x the effective rate limit for external APIs. Configure +//! `rate_limit_requests_per_minute` conservatively when running multiple workers. +//! +//! 4. **Process lifecycle**: Plugin processes are spawned on first use and kept alive for +//! reuse. They are shut down when the parent Codex process exits or during health check +//! failures. +//! +//! ## Security Features +//! +//! - **Command allowlist**: Only whitelisted commands can be executed (see [`process::is_command_allowed`]) +//! - **Credential redaction**: Sensitive values use [`secrets::SecretValue`] to prevent log exposure +//! - **Output size limits**: Plugin responses are limited to prevent memory exhaustion +//! - **Rate limiting**: Per-plugin token bucket rate limiting protects external APIs +//! +//! ## Modules +//! +//! - [`protocol`]: JSON-RPC types, manifest schema, metadata types +//! - [`process`]: Process spawning and stdio management +//! - [`rpc`]: JSON-RPC client over stdio +//! - [`handle`]: Plugin lifecycle management +//! - [`health`]: Health monitoring and failure tracking +//! - [`secrets`]: Secure credential handling with redaction + +pub mod encryption; +pub mod handle; +pub mod health; +pub mod manager; +pub mod process; +pub mod protocol; +pub mod rpc; +pub mod secrets; + +// Re-exports for public API +// Note: Many of these are designed for future use or exposed for the complete API surface. +// The #[allow(unused_imports)] suppresses warnings for exports that aren't yet consumed +// internally but are part of the public module interface. +#[allow(unused_imports)] +pub use handle::PluginHandle; +#[allow(unused_imports)] +pub use health::{HealthMonitor, HealthState, HealthTracker}; +#[allow(unused_imports)] +pub use manager::{PluginManager, PluginManagerConfig, PluginManagerError}; +#[allow(unused_imports)] +pub use protocol::{PluginCapabilities, PluginManifest, PluginSeriesMetadata}; +#[allow(unused_imports)] +pub use rpc::RpcClient; diff --git a/src/services/plugin/process.rs b/src/services/plugin/process.rs new file mode 100644 index 00000000..3e411924 --- /dev/null +++ b/src/services/plugin/process.rs @@ -0,0 +1,896 @@ +//! Plugin Process Spawning and Management +//! +//! This module handles spawning plugin processes and managing their stdio streams. +//! +//! ## Security +//! +//! Plugin commands are validated against an allowlist before execution. This prevents +//! command injection attacks where a compromised admin account could execute arbitrary +//! commands. +//! +//! Default allowed commands: `node`, `npx`, `python`, `python3`, `uv`, `uvx` +//! +//! Custom commands can be added via: +//! - `CODEX_PLUGIN_ALLOWED_COMMANDS` env var (comma-separated list) +//! - Absolute paths starting with `/opt/codex/plugins/` are always allowed +//! +//! Note: This module provides complete process management infrastructure. +//! Some methods and error variants may not be called from external code yet +//! but are part of the complete API for plugin process management. + +// Allow dead code for process management infrastructure that is part of the +// complete API surface but not yet fully integrated. +#![allow(dead_code)] + +use std::collections::HashMap; +use std::path::Path; +use std::process::Stdio; +use std::sync::OnceLock; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; +use tokio::sync::mpsc; +use tracing::{debug, error, warn}; + +// ============================================================================= +// Command Allowlist +// ============================================================================= + +/// Environment variable for customizing allowed plugin commands +pub const ALLOWED_COMMANDS_ENV: &str = "CODEX_PLUGIN_ALLOWED_COMMANDS"; + +/// Default allowed commands for plugin execution +/// +/// These are common runtimes used by plugins: +/// - `node`, `npx`: Node.js plugins +/// - `python`, `python3`: Python plugins +/// - `uv`, `uvx`: Python package runner (uv) +const DEFAULT_ALLOWED_COMMANDS: &[&str] = &["node", "npx", "python", "python3", "uv", "uvx"]; + +/// Path prefixes that are always allowed (for absolute paths to plugin binaries) +const ALLOWED_PATH_PREFIXES: &[&str] = &["/opt/codex/plugins/"]; + +/// Cached allowlist (initialized once on first use) +static COMMAND_ALLOWLIST: OnceLock> = OnceLock::new(); + +// ============================================================================= +// Environment Variable Blocklist +// ============================================================================= + +/// Environment variables that are blocked from being set by plugins. +/// +/// These variables could be used to: +/// - Hijack library loading (LD_*, DYLD_*) +/// - Modify execution path (PATH) +/// - Change shell behavior (SHELL, BASH_*, ZSH_*) +/// - Alter user identity (HOME, USER, LOGNAME) +/// - Inject code into interpreters (PYTHONPATH, NODE_PATH, etc.) +/// +/// This is a blocklist approach because: +/// 1. Most env vars are safe (LOG_LEVEL, API_KEY, etc.) +/// 2. Plugins need flexibility to receive configuration +/// 3. Dangerous vars are a known, finite set +const BLOCKED_ENV_VARS: &[&str] = &[ + // Library loading hijacking + "LD_LIBRARY_PATH", + "LD_PRELOAD", + "LD_AUDIT", + "LD_DEBUG", + "LD_PROFILE", + "DYLD_LIBRARY_PATH", + "DYLD_INSERT_LIBRARIES", + "DYLD_FALLBACK_LIBRARY_PATH", + // Execution path manipulation + "PATH", + "IFS", + // User identity spoofing + "HOME", + "USER", + "LOGNAME", + "USERNAME", + // Shell behavior + "SHELL", + "BASH_ENV", + "ENV", + "CDPATH", + "PROMPT_COMMAND", + // Interpreter code injection + "PYTHONPATH", + "PYTHONSTARTUP", + "PYTHONHOME", + "NODE_PATH", + "NODE_OPTIONS", + "NODE_EXTRA_CA_CERTS", + "RUBYLIB", + "RUBYOPT", + "PERL5LIB", + "PERL5OPT", + // Process debugging/tracing + "LD_DEBUG_OUTPUT", + "MALLOC_CHECK_", + "MALLOC_PERTURB_", + // SSH/Git hijacking + "GIT_SSH", + "GIT_SSH_COMMAND", + "SSH_AUTH_SOCK", + "SSH_ASKPASS", + // Proxy hijacking (could redirect traffic) + "http_proxy", + "https_proxy", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "no_proxy", + // Locale manipulation (can affect string handling) + "LC_ALL", + "LANG", + // Systemd + "NOTIFY_SOCKET", + "LISTEN_FDS", + "LISTEN_PID", +]; + +/// Check if an environment variable name is blocked +/// +/// Returns `true` if the variable is in the blocklist, `false` otherwise. +/// The check is case-insensitive for cross-platform compatibility. +pub fn is_env_var_blocked(name: &str) -> bool { + let name_upper = name.to_uppercase(); + BLOCKED_ENV_VARS + .iter() + .any(|blocked| blocked.to_uppercase() == name_upper) +} + +/// Filter environment variables, removing any that are in the blocklist. +/// +/// Returns a new HashMap with blocked variables removed. +/// Logs a warning for each blocked variable. +pub fn filter_blocked_env_vars(env: &HashMap) -> HashMap { + let mut filtered = HashMap::with_capacity(env.len()); + + for (key, value) in env { + if is_env_var_blocked(key) { + warn!( + "Blocked dangerous environment variable '{}' from being set for plugin", + key + ); + } else { + filtered.insert(key.clone(), value.clone()); + } + } + + filtered +} + +/// Get the command allowlist, initializing from env var if needed +fn get_command_allowlist() -> &'static Vec { + COMMAND_ALLOWLIST.get_or_init(|| { + let mut allowlist: Vec = DEFAULT_ALLOWED_COMMANDS + .iter() + .map(|s| s.to_string()) + .collect(); + + // Add custom commands from environment variable + if let Ok(custom) = std::env::var(ALLOWED_COMMANDS_ENV) { + for cmd in custom.split(',') { + let cmd = cmd.trim(); + if !cmd.is_empty() && !allowlist.contains(&cmd.to_string()) { + allowlist.push(cmd.to_string()); + } + } + } + + allowlist + }) +} + +/// Check if a command is in the allowlist +/// +/// Returns `true` if the command is allowed, `false` otherwise. +/// +/// A command is allowed if: +/// 1. It matches an entry in the allowlist (e.g., "node", "python") +/// 2. It's an absolute path starting with an allowed prefix (e.g., "/opt/codex/plugins/") +/// +/// # Security +/// +/// For absolute paths, symlinks are resolved using `canonicalize()` before checking +/// the path prefix. This prevents symlink bypass attacks where a malicious symlink +/// like `/opt/codex/plugins/malicious -> /bin/rm` could be used to execute arbitrary commands. +pub fn is_command_allowed(command: &str) -> bool { + let allowlist = get_command_allowlist(); + + // Check if command matches an allowlist entry + if allowlist.iter().any(|allowed| allowed == command) { + return true; + } + + // Check if command is an absolute path under an allowed prefix + if command.starts_with('/') { + let path = Path::new(command); + + // Resolve symlinks to prevent bypass attacks + // e.g., /opt/codex/plugins/malicious -> /bin/rm + // If canonicalize fails (file doesn't exist, broken symlink, etc.), + // we reject the command for safety. + let resolved_path = match path.canonicalize() { + Ok(p) => p, + Err(_) => { + // Path doesn't exist or symlink is broken - reject for safety + warn!( + "Command path '{}' could not be resolved (file may not exist)", + command + ); + return false; + } + }; + + for prefix in ALLOWED_PATH_PREFIXES { + if resolved_path.starts_with(prefix) { + return true; + } + } + + // If original path looked like it was under allowed prefix but resolved + // path is not, this is a symlink bypass attempt + let original_under_prefix = ALLOWED_PATH_PREFIXES.iter().any(|p| path.starts_with(p)); + if original_under_prefix { + warn!( + "Possible symlink bypass detected: '{}' resolves to '{}'", + command, + resolved_path.display() + ); + } + } + + false +} + +/// Get a human-readable description of allowed commands for error messages +pub fn allowed_commands_description() -> String { + let allowlist = get_command_allowlist(); + let mut parts: Vec = allowlist.iter().map(|s| format!("`{}`", s)).collect(); + + for prefix in ALLOWED_PATH_PREFIXES { + parts.push(format!("absolute paths under `{}`", prefix)); + } + + parts.join(", ") +} + +// ============================================================================= +// Output Size Limits +// ============================================================================= + +/// Maximum length of a single line from plugin stdout (1 MB) +/// +/// Lines longer than this will be truncated to prevent memory exhaustion +/// from malicious or buggy plugins sending extremely long lines. +pub const MAX_LINE_LENGTH: usize = 1_048_576; // 1 MB + +/// Maximum total bytes read from plugin stdout per session +/// +/// This is a safety limit to prevent memory exhaustion. In practice, +/// most plugin responses are small JSON-RPC messages. +pub const MAX_TOTAL_OUTPUT: usize = 104_857_600; // 100 MB + +// ============================================================================= +// Process Configuration +// ============================================================================= + +/// Configuration for spawning a plugin process +#[derive(Debug, Clone)] +pub struct PluginProcessConfig { + /// Command to execute (e.g., "node", "python", "/path/to/binary") + pub command: String, + /// Arguments to pass to the command + pub args: Vec, + /// Environment variables to set (in addition to current env) + pub env: HashMap, + /// Working directory for the process + pub working_directory: Option, +} + +impl PluginProcessConfig { + /// Create a new process configuration + pub fn new(command: impl Into) -> Self { + Self { + command: command.into(), + args: Vec::new(), + env: HashMap::new(), + working_directory: None, + } + } + + /// Add an argument + pub fn arg(mut self, arg: impl Into) -> Self { + self.args.push(arg.into()); + self + } + + /// Add multiple arguments + pub fn args(mut self, args: impl IntoIterator>) -> Self { + self.args.extend(args.into_iter().map(|a| a.into())); + self + } + + /// Set an environment variable + pub fn env(mut self, key: impl Into, value: impl Into) -> Self { + self.env.insert(key.into(), value.into()); + self + } + + /// Set multiple environment variables + pub fn envs( + mut self, + vars: impl IntoIterator, impl Into)>, + ) -> Self { + for (k, v) in vars { + self.env.insert(k.into(), v.into()); + } + self + } + + /// Set the working directory + pub fn working_directory(mut self, dir: impl Into) -> Self { + self.working_directory = Some(dir.into()); + self + } + + /// Validate that the command is allowed + /// + /// Returns `Ok(())` if the command is in the allowlist, + /// or an error with a descriptive message if not. + pub fn validate_command(&self) -> Result<(), ProcessError> { + if is_command_allowed(&self.command) { + Ok(()) + } else { + Err(ProcessError::CommandNotAllowed { + command: self.command.clone(), + allowed: allowed_commands_description(), + }) + } + } +} + +/// Error type for process operations +#[derive(Debug, thiserror::Error)] +pub enum ProcessError { + #[error("Failed to spawn process: {0}")] + SpawnFailed(#[from] std::io::Error), + + #[error("Command '{command}' is not in the plugin allowlist. Allowed: {allowed}")] + CommandNotAllowed { command: String, allowed: String }, + + #[error("Plugin output line too long ({length} bytes, max {max} bytes)")] + LineTooLong { length: usize, max: usize }, + + #[error("Plugin output exceeded size limit ({total} bytes, max {max} bytes)")] + OutputTooLarge { total: usize, max: usize }, + + #[error("Process stdin not available")] + StdinUnavailable, + + #[error("Process stdout not available")] + StdoutUnavailable, + + #[error("Failed to write to process stdin: {0}")] + WriteFailed(std::io::Error), + + #[error("Failed to read from process stdout: {0}")] + ReadFailed(std::io::Error), + + #[error("Process terminated unexpectedly")] + ProcessTerminated, + + #[error("Process exited with code {0}")] + ExitCode(i32), + + #[error("Channel closed")] + ChannelClosed, +} + +/// A spawned plugin process with stdio handles +pub struct PluginProcess { + /// The child process handle + child: Child, + /// Sender for writing lines to stdin + stdin_tx: mpsc::Sender, + /// Receiver for reading lines from stdout + stdout_rx: mpsc::Receiver, +} + +impl PluginProcess { + /// Spawn a new plugin process + /// + /// # Security + /// + /// - The command is validated against the allowlist before spawning. + /// If the command is not allowed, returns `ProcessError::CommandNotAllowed`. + /// - Environment variables are filtered against a blocklist to prevent + /// dangerous vars like PATH, LD_PRELOAD, etc. from being set. + pub async fn spawn(config: &PluginProcessConfig) -> Result { + // Validate command against allowlist before spawning + config.validate_command()?; + + let mut cmd = Command::new(&config.command); + + // Add arguments + cmd.args(&config.args); + + // Filter and set environment variables (removing blocked vars) + let filtered_env = filter_blocked_env_vars(&config.env); + for (key, value) in &filtered_env { + cmd.env(key, value); + } + + // Set working directory if specified + if let Some(ref dir) = config.working_directory { + cmd.current_dir(dir); + } + + // Configure stdio + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) // Plugin stderr goes to Codex logs + .kill_on_drop(true); + + debug!( + command = %config.command, + args = ?config.args, + "Spawning plugin process" + ); + + let mut child = cmd.spawn()?; + + // Take ownership of stdio handles + let stdin = child.stdin.take().ok_or(ProcessError::StdinUnavailable)?; + let stdout = child.stdout.take().ok_or(ProcessError::StdoutUnavailable)?; + + // Create channels for async IO + let (stdin_tx, stdin_rx) = mpsc::channel::(32); + let (stdout_tx, stdout_rx) = mpsc::channel::(32); + + // Spawn stdin writer task + tokio::spawn(stdin_writer_task(stdin, stdin_rx)); + + // Spawn stdout reader task + tokio::spawn(stdout_reader_task(stdout, stdout_tx)); + + Ok(Self { + child, + stdin_tx, + stdout_rx, + }) + } + + /// Write a line to the process stdin + pub async fn write_line(&self, line: &str) -> Result<(), ProcessError> { + self.stdin_tx + .send(line.to_string()) + .await + .map_err(|_| ProcessError::ChannelClosed) + } + + /// Read a line from the process stdout + pub async fn read_line(&mut self) -> Result { + self.stdout_rx + .recv() + .await + .ok_or(ProcessError::ProcessTerminated) + } + + /// Check if the process is still running + pub fn is_running(&mut self) -> bool { + match self.child.try_wait() { + Ok(None) => true, // Still running + Ok(Some(_)) => false, // Exited + Err(_) => false, // Error checking status + } + } + + /// Get the process ID + pub fn pid(&self) -> Option { + self.child.id() + } + + /// Wait for the process to exit and return the exit code + pub async fn wait(&mut self) -> Result { + let status = self.child.wait().await?; + Ok(status.code().unwrap_or(-1)) + } + + /// Kill the process + pub async fn kill(&mut self) -> Result<(), ProcessError> { + self.child.kill().await.map_err(ProcessError::SpawnFailed) + } + + /// Gracefully shutdown: wait for timeout, then kill + pub async fn shutdown(&mut self, timeout: std::time::Duration) -> Result { + // Try to wait for process to exit gracefully + match tokio::time::timeout(timeout, self.child.wait()).await { + Ok(Ok(status)) => Ok(status.code().unwrap_or(-1)), + Ok(Err(e)) => Err(ProcessError::SpawnFailed(e)), + Err(_) => { + // Timeout - force kill + warn!("Plugin process did not exit gracefully, killing"); + self.kill().await?; + Ok(-1) + } + } + } +} + +/// Task that writes lines to the process stdin +async fn stdin_writer_task(mut stdin: ChildStdin, mut rx: mpsc::Receiver) { + while let Some(line) = rx.recv().await { + let line_with_newline = if line.ends_with('\n') { + line + } else { + format!("{}\n", line) + }; + + if let Err(e) = stdin.write_all(line_with_newline.as_bytes()).await { + error!("Failed to write to plugin stdin: {}", e); + break; + } + + if let Err(e) = stdin.flush().await { + error!("Failed to flush plugin stdin: {}", e); + break; + } + } +} + +/// Task that reads lines from the process stdout with size limits +/// +/// # Size Limits +/// +/// - Lines longer than `MAX_LINE_LENGTH` (1 MB) are truncated with a warning +/// - Total output exceeding `MAX_TOTAL_OUTPUT` (100 MB) causes the reader to stop +/// +/// These limits protect against memory exhaustion from malicious or buggy plugins. +async fn stdout_reader_task(stdout: ChildStdout, tx: mpsc::Sender) { + let mut reader = BufReader::new(stdout); + let mut line = String::new(); + let mut total_bytes: usize = 0; + + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) => { + // EOF - process closed stdout + debug!("Plugin stdout closed (EOF)"); + break; + } + Ok(bytes_read) => { + total_bytes = total_bytes.saturating_add(bytes_read); + + // Check total output limit + if total_bytes > MAX_TOTAL_OUTPUT { + error!( + "Plugin output exceeded size limit ({} bytes, max {} bytes)", + total_bytes, MAX_TOTAL_OUTPUT + ); + break; + } + + // Truncate oversized lines with warning + let mut output = line.trim_end().to_string(); + if output.len() > MAX_LINE_LENGTH { + warn!( + "Plugin output line truncated ({} bytes, max {} bytes)", + output.len(), + MAX_LINE_LENGTH + ); + output.truncate(MAX_LINE_LENGTH); + } + + if tx.send(output).await.is_err() { + // Receiver dropped + break; + } + } + Err(e) => { + error!("Failed to read from plugin stdout: {}", e); + break; + } + } + } + + debug!( + "Plugin stdout reader finished (total: {} bytes)", + total_bytes + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + // ========================================================================= + // Command Allowlist Tests + // ========================================================================= + + #[test] + fn test_default_commands_allowed() { + // Default allowed commands should pass validation + assert!(is_command_allowed("node")); + assert!(is_command_allowed("npx")); + assert!(is_command_allowed("python")); + assert!(is_command_allowed("python3")); + assert!(is_command_allowed("uv")); + assert!(is_command_allowed("uvx")); + } + + #[test] + fn test_arbitrary_commands_blocked() { + // Arbitrary commands should be blocked + assert!(!is_command_allowed("rm")); + assert!(!is_command_allowed("curl")); + assert!(!is_command_allowed("wget")); + assert!(!is_command_allowed("bash")); + assert!(!is_command_allowed("sh")); + assert!(!is_command_allowed("/bin/bash")); + assert!(!is_command_allowed("cat")); + assert!(!is_command_allowed("echo")); + } + + #[test] + fn test_allowed_path_prefix_nonexistent() { + // Paths that don't exist are rejected for safety (canonicalize fails) + // This is the secure behavior - we can't verify where a path points + // if the file doesn't exist + assert!(!is_command_allowed("/opt/codex/plugins/my-plugin")); + assert!(!is_command_allowed("/opt/codex/plugins/metadata/mangabaka")); + + // Paths not under allowed prefix should also be blocked + assert!(!is_command_allowed("/usr/bin/node")); + assert!(!is_command_allowed("/home/user/malicious")); + assert!(!is_command_allowed("/opt/other/plugins/plugin")); + } + + #[test] + fn test_real_paths_not_under_allowed_prefix() { + // Real paths that exist but are not under allowed prefix should be blocked + // Using /bin/sh which exists on all Unix systems + assert!(!is_command_allowed("/bin/sh")); + assert!(!is_command_allowed("/usr/bin/env")); + } + + #[test] + fn test_symlink_bypass_detection() { + // This test verifies the symlink resolution behavior + // A symlink under /opt/codex/plugins/ pointing to /bin/rm would be blocked + // because canonicalize() would resolve it to /bin/rm which is not allowed + + // We can't easily create a symlink in tests without filesystem access, + // but we verify that arbitrary paths outside allowed prefix are blocked + // even if they look like they could be plugin paths + + // Path traversal attempts + assert!(!is_command_allowed("/opt/codex/plugins/../../../bin/rm")); + + // These would fail canonicalize (don't exist) + assert!(!is_command_allowed("/opt/codex/plugins/fake-plugin")); + } + + #[test] + fn test_validate_command_success() { + let config = PluginProcessConfig::new("node").arg("script.js"); + assert!(config.validate_command().is_ok()); + } + + #[test] + fn test_validate_command_failure() { + let config = PluginProcessConfig::new("rm").arg("-rf").arg("/"); + let result = config.validate_command(); + assert!(result.is_err()); + + let err = result.unwrap_err(); + match err { + ProcessError::CommandNotAllowed { command, allowed } => { + assert_eq!(command, "rm"); + assert!(allowed.contains("node")); + } + _ => panic!("Expected CommandNotAllowed error"), + } + } + + #[test] + fn test_allowed_commands_description() { + let desc = allowed_commands_description(); + assert!(desc.contains("`node`")); + assert!(desc.contains("`python`")); + assert!(desc.contains("/opt/codex/plugins/")); + } + + #[tokio::test] + async fn test_spawn_blocked_command() { + // Attempting to spawn a blocked command should fail with CommandNotAllowed + let config = PluginProcessConfig::new("cat"); + let result = PluginProcess::spawn(&config).await; + + assert!(result.is_err()); + match result { + Err(ProcessError::CommandNotAllowed { command, .. }) => { + assert_eq!(command, "cat"); + } + Err(e) => panic!("Expected CommandNotAllowed, got: {:?}", e), + Ok(_) => panic!("Expected error, got Ok"), + } + } + + #[tokio::test] + async fn test_spawn_allowed_command() { + // Spawning an allowed command should work (if the command exists) + // We use 'node --version' which should be available in most dev environments + let config = PluginProcessConfig::new("node").arg("--version"); + + // This may fail if node is not installed, but that's OK for the test + // The important thing is that it doesn't fail with CommandNotAllowed + let result = PluginProcess::spawn(&config).await; + + match result { + Ok(mut process) => { + // Successfully spawned - clean up + let _ = process.kill().await; + } + Err(ProcessError::CommandNotAllowed { .. }) => { + panic!("node should be in the allowlist"); + } + Err(ProcessError::SpawnFailed(_)) => { + // node might not be installed - that's OK + } + Err(e) => { + panic!("Unexpected error: {:?}", e); + } + } + } + + // ========================================================================= + // Process Config Tests + // ========================================================================= + + #[test] + fn test_process_config_builder() { + let config = PluginProcessConfig::new("node") + .arg("script.js") + .args(["--flag", "value"]) + .env("API_KEY", "secret") + .working_directory("/tmp"); + + assert_eq!(config.command, "node"); + assert_eq!(config.args, vec!["script.js", "--flag", "value"]); + assert_eq!(config.env.get("API_KEY"), Some(&"secret".to_string())); + assert_eq!(config.working_directory, Some("/tmp".to_string())); + } + + // ========================================================================= + // Output Size Limits Tests + // ========================================================================= + + #[test] + fn test_size_limit_constants() { + // Verify the size limit constants are reasonable + assert_eq!(MAX_LINE_LENGTH, 1_048_576); // 1 MB + assert_eq!(MAX_TOTAL_OUTPUT, 104_857_600); // 100 MB + + // MAX_TOTAL_OUTPUT should be larger than MAX_LINE_LENGTH (compile-time check) + const _: () = assert!(MAX_TOTAL_OUTPUT > MAX_LINE_LENGTH); + } + + #[test] + fn test_error_variants() { + // Test that the error variants format correctly + let err = ProcessError::LineTooLong { + length: 2_000_000, + max: MAX_LINE_LENGTH, + }; + let msg = err.to_string(); + assert!(msg.contains("2000000")); + assert!(msg.contains("1048576")); + + let err = ProcessError::OutputTooLarge { + total: 200_000_000, + max: MAX_TOTAL_OUTPUT, + }; + let msg = err.to_string(); + assert!(msg.contains("200000000")); + assert!(msg.contains("104857600")); + } + + // ========================================================================= + // Environment Variable Blocklist Tests + // ========================================================================= + + #[test] + fn test_blocked_env_vars_library_loading() { + // Library loading hijacking vars should be blocked + assert!(is_env_var_blocked("LD_LIBRARY_PATH")); + assert!(is_env_var_blocked("LD_PRELOAD")); + assert!(is_env_var_blocked("DYLD_INSERT_LIBRARIES")); + assert!(is_env_var_blocked("DYLD_LIBRARY_PATH")); + } + + #[test] + fn test_blocked_env_vars_path_and_identity() { + // Path and identity vars should be blocked + assert!(is_env_var_blocked("PATH")); + assert!(is_env_var_blocked("HOME")); + assert!(is_env_var_blocked("USER")); + assert!(is_env_var_blocked("SHELL")); + } + + #[test] + fn test_blocked_env_vars_interpreter_injection() { + // Interpreter code injection vars should be blocked + assert!(is_env_var_blocked("PYTHONPATH")); + assert!(is_env_var_blocked("NODE_PATH")); + assert!(is_env_var_blocked("NODE_OPTIONS")); + assert!(is_env_var_blocked("RUBYLIB")); + } + + #[test] + fn test_blocked_env_vars_case_insensitive() { + // Check should be case-insensitive + assert!(is_env_var_blocked("path")); + assert!(is_env_var_blocked("Path")); + assert!(is_env_var_blocked("PATH")); + assert!(is_env_var_blocked("home")); + assert!(is_env_var_blocked("ld_preload")); + } + + #[test] + fn test_safe_env_vars_allowed() { + // Common safe env vars should be allowed + assert!(!is_env_var_blocked("LOG_LEVEL")); + assert!(!is_env_var_blocked("API_KEY")); + assert!(!is_env_var_blocked("DEBUG")); + assert!(!is_env_var_blocked("MY_CUSTOM_VAR")); + assert!(!is_env_var_blocked("MANGABAKA_API_KEY")); + assert!(!is_env_var_blocked("TZ")); // Timezone is safe + } + + #[test] + fn test_filter_blocked_env_vars() { + let mut env = HashMap::new(); + env.insert("API_KEY".to_string(), "secret".to_string()); + env.insert("LOG_LEVEL".to_string(), "debug".to_string()); + env.insert("PATH".to_string(), "/evil/path".to_string()); + env.insert("LD_PRELOAD".to_string(), "/evil/lib.so".to_string()); + env.insert("HOME".to_string(), "/tmp/evil".to_string()); + + let filtered = filter_blocked_env_vars(&env); + + // Safe vars should be kept + assert_eq!(filtered.get("API_KEY"), Some(&"secret".to_string())); + assert_eq!(filtered.get("LOG_LEVEL"), Some(&"debug".to_string())); + + // Dangerous vars should be removed + assert!(!filtered.contains_key("PATH")); + assert!(!filtered.contains_key("LD_PRELOAD")); + assert!(!filtered.contains_key("HOME")); + + // Only 2 vars should remain + assert_eq!(filtered.len(), 2); + } + + #[test] + fn test_filter_empty_env() { + let env = HashMap::new(); + let filtered = filter_blocked_env_vars(&env); + assert!(filtered.is_empty()); + } + + #[test] + fn test_filter_all_safe_env() { + let mut env = HashMap::new(); + env.insert("API_KEY".to_string(), "secret".to_string()); + env.insert("TIMEOUT".to_string(), "30".to_string()); + + let filtered = filter_blocked_env_vars(&env); + assert_eq!(filtered.len(), 2); + assert_eq!(filtered.get("API_KEY"), Some(&"secret".to_string())); + } +} diff --git a/src/services/plugin/protocol.rs b/src/services/plugin/protocol.rs new file mode 100644 index 00000000..0f144553 --- /dev/null +++ b/src/services/plugin/protocol.rs @@ -0,0 +1,867 @@ +//! JSON-RPC Protocol Types for Plugin Communication +//! +//! This module defines the JSON-RPC 2.0 protocol types for communication with plugins, +//! including request/response structures, manifest schema, and metadata types. +//! +//! Note: Many types in this module are part of the plugin protocol specification and +//! are designed for serialization/deserialization. They may not all be used internally +//! yet, but form the complete API contract for plugin communication. + +// Allow dead code for protocol types that are part of the API contract but not yet used internally. +// These types are essential for the complete plugin protocol specification. +#![allow(dead_code)] + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// JSON-RPC protocol version +pub const JSONRPC_VERSION: &str = "2.0"; + +/// Plugin protocol version +pub const PROTOCOL_VERSION: &str = "1.0"; + +// ============================================================================= +// JSON-RPC Base Types +// ============================================================================= + +/// JSON-RPC request identifier +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum RequestId { + Number(i64), + String(String), +} + +impl From for RequestId { + fn from(id: i64) -> Self { + RequestId::Number(id) + } +} + +impl From for RequestId { + fn from(id: String) -> Self { + RequestId::String(id) + } +} + +impl From<&str> for RequestId { + fn from(id: &str) -> Self { + RequestId::String(id.to_string()) + } +} + +/// JSON-RPC request +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcRequest { + pub jsonrpc: String, + pub id: RequestId, + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +impl JsonRpcRequest { + /// Create a new JSON-RPC request + pub fn new(id: impl Into, method: impl Into, params: Option) -> Self { + Self { + jsonrpc: JSONRPC_VERSION.to_string(), + id: id.into(), + method: method.into(), + params, + } + } + + /// Create a request without parameters + pub fn without_params(id: impl Into, method: impl Into) -> Self { + Self::new(id, method, None) + } +} + +/// JSON-RPC response +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcResponse { + pub jsonrpc: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl JsonRpcResponse { + /// Create a successful response + pub fn success(id: RequestId, result: Value) -> Self { + Self { + jsonrpc: JSONRPC_VERSION.to_string(), + id: Some(id), + result: Some(result), + error: None, + } + } + + /// Create an error response + pub fn error(id: Option, error: JsonRpcError) -> Self { + Self { + jsonrpc: JSONRPC_VERSION.to_string(), + id, + result: None, + error: Some(error), + } + } + + /// Check if the response is an error + pub fn is_error(&self) -> bool { + self.error.is_some() + } +} + +/// JSON-RPC error object +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcError { + pub code: i32, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +impl JsonRpcError { + pub fn new(code: i32, message: impl Into) -> Self { + Self { + code, + message: message.into(), + data: None, + } + } + + pub fn with_data(code: i32, message: impl Into, data: Value) -> Self { + Self { + code, + message: message.into(), + data: Some(data), + } + } +} + +// ============================================================================= +// Standard JSON-RPC Error Codes +// ============================================================================= + +/// Standard JSON-RPC error codes +pub mod error_codes { + /// Invalid JSON was received + pub const PARSE_ERROR: i32 = -32700; + /// The JSON sent is not a valid Request object + pub const INVALID_REQUEST: i32 = -32600; + /// The method does not exist / is not available + pub const METHOD_NOT_FOUND: i32 = -32601; + /// Invalid method parameters + pub const INVALID_PARAMS: i32 = -32602; + /// Internal JSON-RPC error + pub const INTERNAL_ERROR: i32 = -32603; + + // Plugin-specific error codes (-32000 to -32099) + // These MUST match the TypeScript SDK (@codex/plugin-sdk) error codes in types/rpc.ts + /// Rate limited by external provider + pub const RATE_LIMITED: i32 = -32001; + /// Resource not found + pub const NOT_FOUND: i32 = -32002; + /// Authentication failed with external provider + pub const AUTH_FAILED: i32 = -32003; + /// External API error (e.g., 400, 500 from upstream provider) + pub const API_ERROR: i32 = -32004; + /// Plugin configuration error + pub const CONFIG_ERROR: i32 = -32005; +} + +// ============================================================================= +// Standard Method Names +// ============================================================================= + +/// Standard method names +pub mod methods { + /// Initialize the plugin and get manifest + pub const INITIALIZE: &str = "initialize"; + /// Graceful shutdown request + pub const SHUTDOWN: &str = "shutdown"; + /// Health check ping + pub const PING: &str = "ping"; + + // Series metadata methods (scoped by content type) + /// Search for series metadata + pub const METADATA_SERIES_SEARCH: &str = "metadata/series/search"; + /// Get full series metadata by external ID + pub const METADATA_SERIES_GET: &str = "metadata/series/get"; + /// Find best match for a series + pub const METADATA_SERIES_MATCH: &str = "metadata/series/match"; + + // Book metadata methods (future) + // pub const METADATA_BOOK_SEARCH: &str = "metadata/book/search"; + // pub const METADATA_BOOK_GET: &str = "metadata/book/get"; + // pub const METADATA_BOOK_MATCH: &str = "metadata/book/match"; +} + +// ============================================================================= +// Plugin Manifest Types +// ============================================================================= + +/// Plugin manifest returned on initialize +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginManifest { + /// Unique identifier (e.g., "mangaupdates") + pub name: String, + /// Display name for UI (e.g., "MangaUpdates") + pub display_name: String, + /// Semantic version (e.g., "1.0.0") + pub version: String, + /// Description of the plugin + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Plugin author + #[serde(default, skip_serializing_if = "Option::is_none")] + pub author: Option, + /// Plugin homepage URL + #[serde(default, skip_serializing_if = "Option::is_none")] + pub homepage: Option, + + /// Protocol version this plugin implements + pub protocol_version: String, + + /// Plugin capabilities + pub capabilities: PluginCapabilities, + + /// Required credentials for this plugin + #[serde(default)] + pub required_credentials: Vec, + + /// JSON Schema for plugin-specific configuration + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_schema: Option, +} + +/// Content types that a metadata provider can support +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum MetadataContentType { + /// Series metadata (manga, comics, etc.) + Series, + // TODO: Add Book variant when book metadata is implemented + // /// Book metadata (individual books, ebooks) + // Book, +} + +/// Plugin capabilities +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginCapabilities { + /// Content types this plugin can provide metadata for + /// e.g., ["series"] or ["series", "book"] + #[serde(default)] + pub metadata_provider: Vec, + /// Can sync user reading progress (v2) + #[serde(default)] + pub user_sync_provider: bool, +} + +impl PluginCapabilities { + /// Check if the plugin can provide series metadata + pub fn can_provide_series_metadata(&self) -> bool { + self.metadata_provider + .contains(&MetadataContentType::Series) + } + + // TODO: Uncomment when book metadata is implemented + // /// Check if the plugin can provide book metadata + // pub fn can_provide_book_metadata(&self) -> bool { + // self.metadata_provider.contains(&MetadataContentType::Book) + // } +} + +/// Credential field definition +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CredentialField { + /// Credential key (e.g., "api_key") + pub key: String, + /// Display label (e.g., "API Key") + pub label: String, + /// Description for the user + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Whether this credential is required + #[serde(default)] + pub required: bool, + /// Whether to mask the value in UI + #[serde(default)] + pub sensitive: bool, + /// Input type for UI + #[serde(default)] + pub credential_type: CredentialType, +} + +/// Credential input type +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CredentialType { + #[default] + String, + Password, + OAuth, +} + +// ============================================================================= +// Plugin Scopes (Server-Side) +// ============================================================================= + +/// Plugin scope defining where it can be invoked (server-side only). +/// +/// Note: Scopes are determined by the server based on plugin capabilities, +/// not declared in the plugin manifest. This enum is used internally by Codex +/// to control where plugins can be invoked. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginScope { + /// Series detail page dropdown (search + auto-match) + #[serde(rename = "series:detail")] + SeriesDetail, + /// Series list bulk actions (auto-match only) + #[serde(rename = "series:bulk")] + SeriesBulk, + /// Library dropdown action (auto-match all series) + #[serde(rename = "library:detail")] + LibraryDetail, + /// Post-analysis hook (auto-match if forced/changed) + #[serde(rename = "library:scan")] + LibraryScan, +} + +impl PluginScope { + /// Get scopes available for series metadata providers + pub fn series_scopes() -> Vec { + vec![ + Self::SeriesDetail, + Self::SeriesBulk, + Self::LibraryDetail, + Self::LibraryScan, + ] + } +} + +// ============================================================================= +// Metadata Types +// ============================================================================= + +/// Parameters for metadata/search +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MetadataSearchParams { + /// Search query + pub query: String, + /// Maximum number of results + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + /// Pagination cursor + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +/// Response from metadata/search +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MetadataSearchResponse { + /// Search results + pub results: Vec, + /// Cursor for next page + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +/// Individual search result +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchResult { + /// External ID from the provider + pub external_id: String, + /// Primary title + pub title: String, + /// Alternative titles + #[serde(default)] + pub alternate_titles: Vec, + /// Year of publication/release + #[serde(default, skip_serializing_if = "Option::is_none")] + pub year: Option, + /// Cover image URL + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cover_url: Option, + /// Relevance score (0.0-1.0). Optional - if not provided, result order is used. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub relevance_score: Option, + /// Preview data for displaying in results list + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preview: Option, +} + +/// Preview data shown in search results +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchResultPreview { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default)] + pub genres: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rating: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +/// Parameters for metadata/get +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MetadataGetParams { + /// External ID to fetch + pub external_id: String, +} + +/// Parameters for metadata/match +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MetadataMatchParams { + /// Title to match + pub title: String, + /// Year hint for matching + #[serde(default, skip_serializing_if = "Option::is_none")] + pub year: Option, + /// Author hint for matching + #[serde(default, skip_serializing_if = "Option::is_none")] + pub author: Option, +} + +/// Full series metadata from a provider +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginSeriesMetadata { + /// External ID from the provider + pub external_id: String, + /// URL to the series on the provider's website + pub external_url: String, + + // Core fields (all optional) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default)] + pub alternate_titles: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub year: Option, + + // Extended metadata + /// Expected total number of books in the series + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_book_count: Option, + /// BCP47 language code (e.g., "en", "ja", "ko") + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language: Option, + /// Age rating (e.g., 0, 13, 16, 18) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub age_rating: Option, + /// Reading direction: "ltr", "rtl", or "ttb" + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reading_direction: Option, + + // Taxonomy + #[serde(default)] + pub genres: Vec, + #[serde(default)] + pub tags: Vec, + + // Credits + #[serde(default)] + pub authors: Vec, + #[serde(default)] + pub artists: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub publisher: Option, + + // Media + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cover_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub banner_url: Option, + + // Rating + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rating: Option, + /// Multiple external ratings from different sources (e.g., AniList, MAL) + #[serde(default)] + pub external_ratings: Vec, + + // External links + #[serde(default)] + pub external_links: Vec, +} + +/// Full book metadata from a provider (for future use) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginBookMetadata { + /// External ID from the provider + pub external_id: String, + /// URL to the book on the provider's website + pub external_url: String, + + // Core fields (all optional) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default)] + pub alternate_titles: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, + + // Book-specific + #[serde(default, skip_serializing_if = "Option::is_none")] + pub volume: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub chapter: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub page_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub release_date: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub isbn: Option, + + // Taxonomy + #[serde(default)] + pub genres: Vec, + #[serde(default)] + pub tags: Vec, + + // Credits + #[serde(default)] + pub authors: Vec, + #[serde(default)] + pub artists: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub publisher: Option, + + // Media + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cover_url: Option, + + // Rating + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rating: Option, + /// Multiple external ratings from different sources + #[serde(default)] + pub external_ratings: Vec, + + // External links + #[serde(default)] + pub external_links: Vec, +} + +/// Alternate title with language info +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AlternateTitle { + pub title: String, + /// ISO 639-1 language code (e.g., "en", "ja") + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language: Option, + /// Title type (e.g., "romaji", "native", "english") + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title_type: Option, +} + +// Re-export SeriesStatus from db entities - this is the canonical source +pub use crate::db::entities::SeriesStatus; + +/// External rating from provider +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalRating { + /// Normalized score (0-100) + pub score: f64, + /// Number of votes + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vote_count: Option, + /// Source name (e.g., "mangaupdates") + pub source: String, +} + +/// External link to other sites +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalLink { + pub url: String, + pub label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub link_type: Option, +} + +/// Type of external link +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ExternalLinkType { + Provider, + Official, + Social, + Purchase, + Read, + Other, +} + +// ============================================================================= +// Initialize Response +// ============================================================================= + +/// Parameters for initialize (usually empty or with config) +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InitializeParams { + /// Plugin configuration from Codex + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, + /// Credentials passed via init message (alternative to env vars) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credentials: Option, +} + +// ============================================================================= +// Rate Limit Error Data +// ============================================================================= + +/// Data included with rate limit errors +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RateLimitErrorData { + pub retry_after_seconds: u64, +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_jsonrpc_request_serialization() { + let request = JsonRpcRequest::new( + 1i64, + "metadata/series/search", + Some(json!({"query": "test"})), + ); + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"jsonrpc\":\"2.0\"")); + assert!(json.contains("\"method\":\"metadata/series/search\"")); + assert!(json.contains("\"id\":1")); + } + + #[test] + fn test_jsonrpc_request_without_params() { + let request = JsonRpcRequest::without_params(1i64, "ping"); + let json = serde_json::to_string(&request).unwrap(); + assert!(!json.contains("params")); + } + + #[test] + fn test_jsonrpc_response_success() { + let response = JsonRpcResponse::success(RequestId::Number(1), json!({"status": "ok"})); + assert!(!response.is_error()); + assert!(response.result.is_some()); + assert!(response.error.is_none()); + } + + #[test] + fn test_jsonrpc_response_error() { + let response = JsonRpcResponse::error( + Some(RequestId::Number(1)), + JsonRpcError::new(error_codes::NOT_FOUND, "Resource not found"), + ); + assert!(response.is_error()); + assert!(response.result.is_none()); + assert!(response.error.is_some()); + } + + #[test] + fn test_request_id_from_i64() { + let id: RequestId = 42i64.into(); + assert_eq!(id, RequestId::Number(42)); + } + + #[test] + fn test_request_id_from_string() { + let id: RequestId = "abc-123".into(); + assert_eq!(id, RequestId::String("abc-123".to_string())); + } + + #[test] + fn test_plugin_manifest_deserialization() { + let json = json!({ + "name": "test-plugin", + "displayName": "Test Plugin", + "version": "1.0.0", + "protocolVersion": "1.0", + "capabilities": { + "metadataProvider": ["series"] + } + }); + + let manifest: PluginManifest = serde_json::from_value(json).unwrap(); + assert_eq!(manifest.name, "test-plugin"); + assert_eq!(manifest.display_name, "Test Plugin"); + assert!(manifest.capabilities.can_provide_series_metadata()); + } + + // TODO: Re-enable when book metadata is implemented + // #[test] + // fn test_plugin_manifest_with_multiple_content_types() { + // let json = json!({ + // "name": "multi-provider", + // "displayName": "Multi Provider", + // "version": "1.0.0", + // "protocolVersion": "1.0", + // "capabilities": { + // "metadataProvider": ["series", "book"] + // } + // }); + // + // let manifest: PluginManifest = serde_json::from_value(json).unwrap(); + // assert!(manifest.capabilities.can_provide_series_metadata()); + // assert!(manifest.capabilities.can_provide_book_metadata()); + // } + + #[test] + fn test_plugin_manifest_empty_capabilities() { + let json = json!({ + "name": "empty-plugin", + "displayName": "Empty Plugin", + "version": "1.0.0", + "protocolVersion": "1.0", + "capabilities": {} + }); + + let manifest: PluginManifest = serde_json::from_value(json).unwrap(); + assert!(!manifest.capabilities.can_provide_series_metadata()); + } + + #[test] + fn test_metadata_search_params() { + let params = MetadataSearchParams { + query: "One Piece".to_string(), + limit: Some(10), + cursor: None, + }; + let json = serde_json::to_value(¶ms).unwrap(); + assert_eq!(json["query"], "One Piece"); + assert_eq!(json["limit"], 10); + assert!(!json.as_object().unwrap().contains_key("cursor")); + } + + #[test] + fn test_search_result_deserialization() { + let json = json!({ + "externalId": "12345", + "title": "One Piece", + "alternateTitles": ["ワンピース"], + "year": 1997, + "relevanceScore": 0.98, + "preview": { + "status": "ongoing", + "genres": ["Action", "Adventure"] + } + }); + + let result: SearchResult = serde_json::from_value(json).unwrap(); + assert_eq!(result.external_id, "12345"); + assert_eq!(result.title, "One Piece"); + assert_eq!(result.year, Some(1997)); + assert_eq!(result.relevance_score, Some(0.98)); + assert!(result.preview.is_some()); + } + + #[test] + fn test_series_metadata_full() { + let metadata = PluginSeriesMetadata { + external_id: "12345".to_string(), + external_url: "https://example.com/series/12345".to_string(), + title: Some("One Piece".to_string()), + alternate_titles: vec![AlternateTitle { + title: "ワンピース".to_string(), + language: Some("ja".to_string()), + title_type: Some("native".to_string()), + }], + summary: Some("A pirate adventure".to_string()), + status: Some(SeriesStatus::Ongoing), + year: Some(1997), + total_book_count: Some(100), + language: Some("ja".to_string()), + age_rating: Some(13), + reading_direction: Some("rtl".to_string()), + genres: vec!["Action".to_string(), "Adventure".to_string()], + tags: vec!["pirates".to_string()], + authors: vec!["Oda, Eiichiro".to_string()], + artists: vec![], + publisher: Some("Shueisha".to_string()), + cover_url: Some("https://example.com/cover.jpg".to_string()), + banner_url: None, + rating: Some(ExternalRating { + score: 91.0, + vote_count: Some(50000), + source: "example".to_string(), + }), + external_ratings: vec![], + external_links: vec![], + }; + + let json = serde_json::to_value(&metadata).unwrap(); + assert_eq!(json["externalId"], "12345"); + assert_eq!(json["status"], "ongoing"); + } + + #[test] + fn test_credential_field() { + let field = CredentialField { + key: "api_key".to_string(), + label: "API Key".to_string(), + description: Some("Get your API key from...".to_string()), + required: true, + sensitive: true, + credential_type: CredentialType::Password, + }; + + let json = serde_json::to_value(&field).unwrap(); + assert_eq!(json["key"], "api_key"); + assert_eq!(json["credentialType"], "password"); + assert!(json["sensitive"].as_bool().unwrap()); + } + + #[test] + fn test_error_codes() { + assert_eq!(error_codes::PARSE_ERROR, -32700); + assert_eq!(error_codes::RATE_LIMITED, -32001); + assert_eq!(error_codes::NOT_FOUND, -32002); + assert_eq!(error_codes::AUTH_FAILED, -32003); + assert_eq!(error_codes::API_ERROR, -32004); + assert_eq!(error_codes::CONFIG_ERROR, -32005); + } + + #[test] + fn test_jsonrpc_error_with_data() { + let error = JsonRpcError::with_data( + error_codes::RATE_LIMITED, + "Rate limited", + json!({"retryAfterSeconds": 60}), + ); + assert_eq!(error.code, -32001); + assert_eq!(error.message, "Rate limited"); + assert!(error.data.is_some()); + } +} diff --git a/src/services/plugin/rpc.rs b/src/services/plugin/rpc.rs new file mode 100644 index 00000000..aa06b4c9 --- /dev/null +++ b/src/services/plugin/rpc.rs @@ -0,0 +1,533 @@ +//! JSON-RPC Client for Plugin Communication +//! +//! This module provides a JSON-RPC client that communicates with plugins over stdio. +//! +//! Note: This module provides complete JSON-RPC client infrastructure. +//! Some methods may not be called from external code yet but are part of +//! the complete API for plugin RPC communication. + +// Allow dead code for RPC client infrastructure that is part of the +// complete API surface but not yet fully integrated. +#![allow(dead_code)] + +use std::collections::HashMap; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use serde::de::DeserializeOwned; +use serde::Serialize; +use serde_json::Value; +use tokio::sync::{oneshot, Mutex}; +use tokio::time::timeout; +use tracing::{debug, error, trace, warn}; + +use super::process::{PluginProcess, ProcessError}; +use super::protocol::{ + error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, RequestId, JSONRPC_VERSION, +}; + +/// Error type for RPC operations +#[derive(Debug, thiserror::Error)] +pub enum RpcError { + #[error("Process error: {0}")] + Process(#[from] ProcessError), + + #[error("Serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + #[error("Request timed out after {0:?}")] + Timeout(Duration), + + #[error("Plugin error: {message}")] + PluginError { + code: i32, + message: String, + data: Option, + }, + + #[error("Invalid response: {0}")] + InvalidResponse(String), + + #[error("Request cancelled")] + Cancelled, + + #[error("Rate limited: retry after {retry_after_seconds} seconds")] + RateLimited { retry_after_seconds: u64 }, + + #[error("Resource not found: {0}")] + NotFound(String), + + #[error("Authentication failed: {0}")] + AuthFailed(String), + + #[error("External API error: {0}")] + ApiError(String), + + #[error("Plugin configuration error: {0}")] + ConfigError(String), +} + +impl From for RpcError { + fn from(err: JsonRpcError) -> Self { + match err.code { + error_codes::RATE_LIMITED => { + let retry_after = err + .data + .as_ref() + .and_then(|d| d.get("retryAfterSeconds")) + .and_then(|v| v.as_u64()) + .unwrap_or(60); + RpcError::RateLimited { + retry_after_seconds: retry_after, + } + } + error_codes::NOT_FOUND => RpcError::NotFound(err.message), + error_codes::AUTH_FAILED => RpcError::AuthFailed(err.message), + error_codes::API_ERROR => RpcError::ApiError(err.message), + error_codes::CONFIG_ERROR => RpcError::ConfigError(err.message), + _ => RpcError::PluginError { + code: err.code, + message: err.message, + data: err.data, + }, + } + } +} + +/// Pending request waiting for a response +struct PendingRequest { + tx: oneshot::Sender>, +} + +/// JSON-RPC client for communicating with a plugin process +pub struct RpcClient { + /// The plugin process + process: Arc>, + /// Next request ID + next_id: AtomicI64, + /// Pending requests waiting for responses + pending: Arc>>, + /// Default request timeout + default_timeout: Duration, + /// Response reader task handle + reader_handle: Option>, +} + +impl RpcClient { + /// Create a new RPC client wrapping a plugin process + pub fn new(process: PluginProcess, default_timeout: Duration) -> Self { + let process = Arc::new(Mutex::new(process)); + let pending: Arc>> = + Arc::new(Mutex::new(HashMap::new())); + + // Start the response reader task + let reader_handle = { + let process = Arc::clone(&process); + let pending = Arc::clone(&pending); + tokio::spawn(async move { + response_reader_task(process, pending).await; + }) + }; + + Self { + process, + next_id: AtomicI64::new(1), + pending, + default_timeout, + reader_handle: Some(reader_handle), + } + } + + /// Send a request and wait for a response + pub async fn call(&self, method: &str, params: P) -> Result + where + P: Serialize, + R: DeserializeOwned, + { + self.call_with_timeout(method, params, self.default_timeout) + .await + } + + /// Send a request and wait for a response with custom timeout + pub async fn call_with_timeout( + &self, + method: &str, + params: P, + request_timeout: Duration, + ) -> Result + where + P: Serialize, + R: DeserializeOwned, + { + let id = self.next_id.fetch_add(1, Ordering::SeqCst); + let params_value = serde_json::to_value(params)?; + + let request = JsonRpcRequest { + jsonrpc: JSONRPC_VERSION.to_string(), + id: RequestId::Number(id), + method: method.to_string(), + params: if params_value.is_null() { + None + } else { + Some(params_value) + }, + }; + + let request_json = serde_json::to_string(&request)?; + trace!(id, method, "Sending RPC request"); + + // Create response channel + let (tx, rx) = oneshot::channel(); + { + let mut pending = self.pending.lock().await; + pending.insert(id, PendingRequest { tx }); + } + + // Send request + { + let process = self.process.lock().await; + process.write_line(&request_json).await?; + } + + // Wait for response with timeout + let result = match timeout(request_timeout, rx).await { + Ok(Ok(result)) => result, + Ok(Err(_)) => { + // Channel was closed (cancelled) + self.remove_pending(id).await; + return Err(RpcError::Cancelled); + } + Err(_) => { + // Timeout + self.remove_pending(id).await; + return Err(RpcError::Timeout(request_timeout)); + } + }; + + // Parse the result + let value = result?; + let parsed: R = serde_json::from_value(value)?; + Ok(parsed) + } + + /// Send a request without parameters + pub async fn call_no_params(&self, method: &str) -> Result + where + R: DeserializeOwned, + { + self.call::<(), R>(method, ()).await + } + + /// Send a notification (no response expected) + pub async fn notify

(&self, method: &str, params: P) -> Result<(), RpcError> + where + P: Serialize, + { + let params_value = serde_json::to_value(params)?; + + // Notifications don't have an id + let request = serde_json::json!({ + "jsonrpc": JSONRPC_VERSION, + "method": method, + "params": params_value, + }); + + let request_json = serde_json::to_string(&request)?; + trace!(method, "Sending RPC notification"); + + let process = self.process.lock().await; + process.write_line(&request_json).await?; + Ok(()) + } + + /// Check if the underlying process is still running + pub async fn is_running(&self) -> bool { + let mut process = self.process.lock().await; + process.is_running() + } + + /// Get the process ID + pub async fn pid(&self) -> Option { + let process = self.process.lock().await; + process.pid() + } + + /// Shutdown the RPC client and kill the process + pub async fn shutdown(&mut self, timeout_duration: Duration) -> Result { + // Cancel the reader task + if let Some(handle) = self.reader_handle.take() { + handle.abort(); + } + + // Cancel all pending requests + { + let mut pending = self.pending.lock().await; + for (_, req) in pending.drain() { + let _ = req.tx.send(Err(RpcError::Cancelled)); + } + } + + // Shutdown the process + let mut process = self.process.lock().await; + process.shutdown(timeout_duration).await + } + + /// Remove a pending request + async fn remove_pending(&self, id: i64) { + let mut pending = self.pending.lock().await; + pending.remove(&id); + } +} + +/// Task that reads responses from the process and dispatches them +async fn response_reader_task( + process: Arc>, + pending: Arc>>, +) { + loop { + // Acquire lock briefly and use timeout to prevent holding lock while waiting + // This allows write operations to acquire the lock between read attempts + let line = { + let mut process = process.lock().await; + match tokio::time::timeout(Duration::from_millis(100), process.read_line()).await { + Ok(Ok(line)) => Some(line), + Ok(Err(e)) => { + debug!("Response reader stopping: {}", e); + break; + } + Err(_) => None, // Timeout - release lock and retry + } + }; + + // If timeout, loop to try again (releases lock first) + let line = match line { + Some(l) => l, + None => continue, + }; + + if line.is_empty() { + continue; + } + + trace!(line = %line, "Received line from plugin"); + + // Parse the response + let response: JsonRpcResponse = match serde_json::from_str(&line) { + Ok(r) => r, + Err(e) => { + warn!("Failed to parse plugin response: {} - line: {}", e, line); + continue; + } + }; + + // Get the request ID + let id = match &response.id { + Some(RequestId::Number(id)) => *id, + Some(RequestId::String(id)) => match id.parse::() { + Ok(id) => id, + Err(_) => { + warn!("Invalid string request ID: {}", id); + continue; + } + }, + None => { + // This is a notification or error without ID + if let Some(err) = response.error { + error!( + "Plugin error without request ID: {} (code: {})", + err.message, err.code + ); + } + continue; + } + }; + + // Find and complete the pending request + let pending_req = { + let mut pending_map = pending.lock().await; + pending_map.remove(&id) + }; + + if let Some(req) = pending_req { + let result = if let Some(err) = response.error { + Err(RpcError::from(err)) + } else if let Some(result) = response.result { + Ok(result) + } else { + Err(RpcError::InvalidResponse( + "Response has neither result nor error".to_string(), + )) + }; + + if req.tx.send(result).is_err() { + debug!("Request {} receiver dropped", id); + } + } else { + warn!("Received response for unknown request ID: {}", id); + } + } + + // Process ended - cancel all pending requests + let mut pending_map = pending.lock().await; + for (id, req) in pending_map.drain() { + debug!("Cancelling pending request {} due to process exit", id); + let _ = req + .tx + .send(Err(RpcError::Process(ProcessError::ProcessTerminated))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // Helper to create a mock plugin script (used in integration tests) + #[allow(dead_code)] + fn create_mock_plugin_script() -> String { + // This is a simple Node.js script that echoes requests + // In real tests, we'd use a proper mock plugin + r#" + const readline = require('readline'); + const rl = readline.createInterface({ input: process.stdin }); + + rl.on('line', (line) => { + try { + const request = JSON.parse(line); + let response; + + if (request.method === 'initialize') { + response = { + jsonrpc: '2.0', + id: request.id, + result: { + name: 'test-plugin', + displayName: 'Test Plugin', + version: '1.0.0', + protocolVersion: '1.0', + capabilities: { metadataProvider: ['series'] } + } + }; + } else if (request.method === 'ping') { + response = { + jsonrpc: '2.0', + id: request.id, + result: 'pong' + }; + } else if (request.method === 'echo') { + response = { + jsonrpc: '2.0', + id: request.id, + result: request.params + }; + } else { + response = { + jsonrpc: '2.0', + id: request.id, + error: { code: -32601, message: 'Method not found' } + }; + } + + console.log(JSON.stringify(response)); + } catch (e) { + console.log(JSON.stringify({ + jsonrpc: '2.0', + id: null, + error: { code: -32700, message: 'Parse error' } + })); + } + }); + "# + .to_string() + } + + #[test] + fn test_rpc_error_from_json_error() { + let err = JsonRpcError::new(error_codes::NOT_FOUND, "Series not found"); + let rpc_err = RpcError::from(err); + assert!(matches!(rpc_err, RpcError::NotFound(_))); + } + + #[test] + fn test_rpc_error_rate_limited() { + let err = JsonRpcError::with_data( + error_codes::RATE_LIMITED, + "Rate limited", + json!({"retryAfterSeconds": 120}), + ); + let rpc_err = RpcError::from(err); + match rpc_err { + RpcError::RateLimited { + retry_after_seconds, + } => { + assert_eq!(retry_after_seconds, 120); + } + _ => panic!("Expected RateLimited error"), + } + } + + #[test] + fn test_rpc_error_auth_failed() { + let err = JsonRpcError::new(error_codes::AUTH_FAILED, "Invalid API key"); + let rpc_err = RpcError::from(err); + assert!(matches!(rpc_err, RpcError::AuthFailed(_))); + } + + #[test] + fn test_rpc_error_api_error() { + let err = JsonRpcError::new(error_codes::API_ERROR, "API error: 400 Bad Request"); + let rpc_err = RpcError::from(err); + match rpc_err { + RpcError::ApiError(msg) => { + assert_eq!(msg, "API error: 400 Bad Request"); + } + _ => panic!("Expected ApiError"), + } + } + + #[test] + fn test_rpc_error_config_error() { + let err = JsonRpcError::new(error_codes::CONFIG_ERROR, "API key is required"); + let rpc_err = RpcError::from(err); + match rpc_err { + RpcError::ConfigError(msg) => { + assert_eq!(msg, "API key is required"); + } + _ => panic!("Expected ConfigError"), + } + } + + // Integration test with actual process would look like: + // #[tokio::test] + // async fn test_rpc_client_integration() { + // // This would require Node.js to be installed + // // Skip if not available + // if std::process::Command::new("node").arg("--version").status().is_err() { + // return; + // } + // + // // Create temp file with mock plugin script + // let script = create_mock_plugin_script(); + // let temp_dir = tempfile::tempdir().unwrap(); + // let script_path = temp_dir.path().join("mock-plugin.js"); + // std::fs::write(&script_path, script).unwrap(); + // + // let config = PluginProcessConfig::new("node") + // .arg(script_path.to_str().unwrap()); + // + // let process = PluginProcess::spawn(&config).await.unwrap(); + // let mut client = RpcClient::new(process, Duration::from_secs(5)); + // + // // Test ping + // let pong: String = client.call_no_params("ping").await.unwrap(); + // assert_eq!(pong, "pong"); + // + // // Test echo + // let echoed: Value = client.call("echo", json!({"test": "data"})).await.unwrap(); + // assert_eq!(echoed["test"], "data"); + // + // // Cleanup + // client.shutdown(Duration::from_secs(1)).await.unwrap(); + // } +} diff --git a/src/services/plugin/secrets.rs b/src/services/plugin/secrets.rs new file mode 100644 index 00000000..5e98b301 --- /dev/null +++ b/src/services/plugin/secrets.rs @@ -0,0 +1,172 @@ +//! Secret handling utilities for plugin credentials. +//! +//! This module provides types that safely handle sensitive data like API keys, +//! tokens, and passwords. The main type is `SecretValue` which wraps `serde_json::Value` +//! and redacts its contents in Debug and Display implementations. +//! +//! ## Usage +//! +//! ```rust +//! use serde_json::json; +//! use codex::services::plugin::secrets::SecretValue; +//! +//! let secret = SecretValue::new(json!({"api_key": "sk-12345"})); +//! +//! // Debug output shows [REDACTED], not the actual value +//! println!("{:?}", secret); // SecretValue([REDACTED]) +//! +//! // Access the actual value when needed +//! let value = secret.inner(); +//! ``` + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::fmt; + +/// A wrapper for sensitive JSON values that redacts content in logs. +/// +/// This type wraps a `serde_json::Value` that may contain sensitive data +/// (API keys, tokens, passwords, etc.) and ensures that the actual content +/// is never exposed in debug output or logs. +/// +/// The value is still accessible through `inner()` when actually needed. +#[derive(Clone)] +pub struct SecretValue(Value); + +impl SecretValue { + /// Create a new secret value wrapper + pub fn new(value: Value) -> Self { + Self(value) + } + + /// Get a reference to the underlying value + pub fn inner(&self) -> &Value { + &self.0 + } + + /// Consume the wrapper and return the underlying value + pub fn into_inner(self) -> Value { + self.0 + } +} + +impl fmt::Debug for SecretValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "SecretValue([REDACTED])") + } +} + +impl fmt::Display for SecretValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "[REDACTED]") + } +} + +impl From for SecretValue { + fn from(value: Value) -> Self { + Self(value) + } +} + +impl From for Value { + fn from(secret: SecretValue) -> Self { + secret.0 + } +} + +impl Default for SecretValue { + fn default() -> Self { + Self(Value::Null) + } +} + +// Serialize passes through to underlying value (for sending to plugins) +impl Serialize for SecretValue { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.0.serialize(serializer) + } +} + +// Deserialize creates a SecretValue wrapper +impl<'de> Deserialize<'de> for SecretValue { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = Value::deserialize(deserializer)?; + Ok(Self(value)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_debug_redacts_value() { + let secret = SecretValue::new(json!({"api_key": "super_secret_key_12345"})); + let debug_output = format!("{:?}", secret); + + assert_eq!(debug_output, "SecretValue([REDACTED])"); + assert!(!debug_output.contains("super_secret")); + assert!(!debug_output.contains("api_key")); + } + + #[test] + fn test_display_redacts_value() { + let secret = SecretValue::new(json!({"password": "hunter2"})); + let display_output = format!("{}", secret); + + assert_eq!(display_output, "[REDACTED]"); + assert!(!display_output.contains("hunter2")); + } + + #[test] + fn test_inner_provides_access() { + let original = json!({"token": "abc123"}); + let secret = SecretValue::new(original.clone()); + + assert_eq!(secret.inner(), &original); + } + + #[test] + fn test_into_inner_returns_value() { + let original = json!({"secret": "value"}); + let secret = SecretValue::new(original.clone()); + + assert_eq!(secret.into_inner(), original); + } + + #[test] + fn test_serialization_passes_through() { + let original = json!({"api_key": "test123"}); + let secret = SecretValue::new(original.clone()); + + // Serialize the secret value + let serialized = serde_json::to_string(&secret).unwrap(); + + // The actual JSON should be serialized, not [REDACTED] + assert_eq!(serialized, r#"{"api_key":"test123"}"#); + } + + #[test] + fn test_from_value() { + let value = json!({"key": "value"}); + let secret: SecretValue = value.clone().into(); + + assert_eq!(secret.inner(), &value); + } + + #[test] + fn test_into_value() { + let original = json!({"data": "secret"}); + let secret = SecretValue::new(original.clone()); + let value: Value = secret.into(); + + assert_eq!(value, original); + } +} diff --git a/src/services/plugin_metrics.rs b/src/services/plugin_metrics.rs new file mode 100644 index 00000000..b53bc4a3 --- /dev/null +++ b/src/services/plugin_metrics.rs @@ -0,0 +1,660 @@ +//! Plugin Metrics Service +//! +//! Provides in-memory metrics collection for plugin operations. +//! This service tracks: +//! - Request counts by plugin and method +//! - Request durations +//! - Rate limit rejections +//! - Failure counts by error code +//! - Plugin health status +//! +//! Unlike task metrics, plugin metrics are ephemeral (in-memory only) +//! since they're primarily for real-time observability rather than +//! historical analysis. + +use chrono::{DateTime, Utc}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use tokio::sync::RwLock; +use uuid::Uuid; + +/// Plugin health status +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginHealthStatus { + /// Plugin is healthy (recent success, no failures) + Healthy, + /// Plugin has some failures but is still operational + Degraded, + /// Plugin is unhealthy (many failures or disabled) + Unhealthy, + /// Plugin health status is unknown (no recent operations) + Unknown, +} + +impl PluginHealthStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Healthy => "healthy", + Self::Degraded => "degraded", + Self::Unhealthy => "unhealthy", + Self::Unknown => "unknown", + } + } +} + +/// Atomic counters for a single plugin's metrics +#[derive(Debug)] +struct PluginCounters { + /// Total requests made + requests_total: AtomicU64, + /// Successful requests + requests_success: AtomicU64, + /// Failed requests + requests_failed: AtomicU64, + /// Total request duration in milliseconds + total_duration_ms: AtomicU64, + /// Rate limit rejections + rate_limit_rejections: AtomicU64, +} + +impl Default for PluginCounters { + fn default() -> Self { + Self { + requests_total: AtomicU64::new(0), + requests_success: AtomicU64::new(0), + requests_failed: AtomicU64::new(0), + total_duration_ms: AtomicU64::new(0), + rate_limit_rejections: AtomicU64::new(0), + } + } +} + +/// Per-method counters within a plugin +#[derive(Debug, Default)] +struct MethodCounters { + /// Requests by method name + by_method: HashMap, +} + +/// Failure tracking for a plugin +#[derive(Debug)] +struct FailureRecord { + /// Error code (e.g., "INIT_ERROR", "TIMEOUT") + error_code: String, + /// Timestamp of the failure (useful for future time-based analysis) + #[allow(dead_code)] + timestamp: DateTime, +} + +/// Entry for a single plugin's metrics +struct PluginMetricsEntry { + /// Plugin ID + plugin_id: Uuid, + /// Plugin name (for display) + plugin_name: String, + /// Aggregate counters + counters: PluginCounters, + /// Per-method breakdown + method_counters: MethodCounters, + /// Recent failures for pattern analysis + recent_failures: Vec, + /// Last successful request timestamp + last_success: Option>, + /// Last failure timestamp + last_failure: Option>, + /// Current health status + health_status: PluginHealthStatus, +} + +impl PluginMetricsEntry { + fn new(plugin_id: Uuid, plugin_name: String) -> Self { + Self { + plugin_id, + plugin_name, + counters: PluginCounters::default(), + method_counters: MethodCounters::default(), + recent_failures: Vec::new(), + last_success: None, + last_failure: None, + health_status: PluginHealthStatus::Unknown, + } + } +} + +/// Snapshot of metrics for a single plugin (returned by API) +#[derive(Debug, Clone)] +pub struct PluginMetricsSnapshot { + pub plugin_id: Uuid, + pub plugin_name: String, + pub requests_total: u64, + pub requests_success: u64, + pub requests_failed: u64, + pub avg_duration_ms: f64, + pub rate_limit_rejections: u64, + pub error_rate_pct: f64, + pub last_success: Option>, + pub last_failure: Option>, + pub health_status: PluginHealthStatus, + pub by_method: HashMap, + pub failure_counts: HashMap, +} + +/// Metrics breakdown by method +#[derive(Debug, Clone)] +pub struct MethodMetrics { + pub method: String, + pub requests_total: u64, + pub requests_success: u64, + pub requests_failed: u64, + pub avg_duration_ms: f64, +} + +/// Summary metrics across all plugins +#[derive(Debug, Clone)] +pub struct PluginMetricsSummary { + pub total_plugins: u64, + pub healthy_plugins: u64, + pub degraded_plugins: u64, + pub unhealthy_plugins: u64, + pub total_requests: u64, + pub total_success: u64, + pub total_failed: u64, + pub total_rate_limit_rejections: u64, +} + +/// Service for collecting and aggregating plugin metrics +#[derive(Clone)] +pub struct PluginMetricsService { + /// Per-plugin metrics + plugins: Arc>>, + /// Maximum recent failures to keep per plugin + max_recent_failures: usize, +} + +impl Default for PluginMetricsService { + fn default() -> Self { + Self::new() + } +} + +impl PluginMetricsService { + /// Create a new plugin metrics service + pub fn new() -> Self { + Self { + plugins: Arc::new(RwLock::new(HashMap::new())), + max_recent_failures: 100, + } + } + + /// Record a successful plugin request + pub async fn record_success( + &self, + plugin_id: Uuid, + plugin_name: &str, + method: &str, + duration_ms: u64, + ) { + let mut plugins = self.plugins.write().await; + let entry = plugins + .entry(plugin_id) + .or_insert_with(|| PluginMetricsEntry::new(plugin_id, plugin_name.to_string())); + + // Update aggregate counters + entry + .counters + .requests_total + .fetch_add(1, Ordering::Relaxed); + entry + .counters + .requests_success + .fetch_add(1, Ordering::Relaxed); + entry + .counters + .total_duration_ms + .fetch_add(duration_ms, Ordering::Relaxed); + entry.last_success = Some(Utc::now()); + + // Update per-method counters + let method_counters = entry + .method_counters + .by_method + .entry(method.to_string()) + .or_default(); + method_counters + .requests_total + .fetch_add(1, Ordering::Relaxed); + method_counters + .requests_success + .fetch_add(1, Ordering::Relaxed); + method_counters + .total_duration_ms + .fetch_add(duration_ms, Ordering::Relaxed); + + // Update health status + entry.health_status = self.calculate_health(&entry.counters, entry.last_failure); + } + + /// Record a failed plugin request + pub async fn record_failure( + &self, + plugin_id: Uuid, + plugin_name: &str, + method: &str, + duration_ms: u64, + error_code: Option<&str>, + ) { + let mut plugins = self.plugins.write().await; + let entry = plugins + .entry(plugin_id) + .or_insert_with(|| PluginMetricsEntry::new(plugin_id, plugin_name.to_string())); + + // Update aggregate counters + entry + .counters + .requests_total + .fetch_add(1, Ordering::Relaxed); + entry + .counters + .requests_failed + .fetch_add(1, Ordering::Relaxed); + entry + .counters + .total_duration_ms + .fetch_add(duration_ms, Ordering::Relaxed); + entry.last_failure = Some(Utc::now()); + + // Update per-method counters + let method_counters = entry + .method_counters + .by_method + .entry(method.to_string()) + .or_default(); + method_counters + .requests_total + .fetch_add(1, Ordering::Relaxed); + method_counters + .requests_failed + .fetch_add(1, Ordering::Relaxed); + method_counters + .total_duration_ms + .fetch_add(duration_ms, Ordering::Relaxed); + + // Record failure for analysis + let error_code = error_code.unwrap_or("UNKNOWN").to_string(); + entry.recent_failures.push(FailureRecord { + error_code, + timestamp: Utc::now(), + }); + + // Trim to max size + if entry.recent_failures.len() > self.max_recent_failures { + entry.recent_failures.remove(0); + } + + // Update health status + entry.health_status = self.calculate_health(&entry.counters, entry.last_failure); + } + + /// Record a rate limit rejection + pub async fn record_rate_limit(&self, plugin_id: Uuid, plugin_name: &str) { + let mut plugins = self.plugins.write().await; + let entry = plugins + .entry(plugin_id) + .or_insert_with(|| PluginMetricsEntry::new(plugin_id, plugin_name.to_string())); + + entry + .counters + .rate_limit_rejections + .fetch_add(1, Ordering::Relaxed); + } + + /// Update plugin health status directly (e.g., when disabled) + pub async fn set_health_status(&self, plugin_id: Uuid, status: PluginHealthStatus) { + let mut plugins = self.plugins.write().await; + if let Some(entry) = plugins.get_mut(&plugin_id) { + entry.health_status = status; + } + } + + /// Get metrics snapshot for a specific plugin + #[allow(dead_code)] // Used in tests; useful for future single-plugin endpoint + pub async fn get_plugin_metrics(&self, plugin_id: Uuid) -> Option { + let plugins = self.plugins.read().await; + plugins + .get(&plugin_id) + .map(|entry| self.build_snapshot(entry)) + } + + /// Get metrics snapshots for all plugins + pub async fn get_all_metrics(&self) -> Vec { + let plugins = self.plugins.read().await; + plugins + .values() + .map(|entry| self.build_snapshot(entry)) + .collect() + } + + /// Get summary metrics across all plugins + pub async fn get_summary(&self) -> PluginMetricsSummary { + let plugins = self.plugins.read().await; + + let mut summary = PluginMetricsSummary { + total_plugins: plugins.len() as u64, + healthy_plugins: 0, + degraded_plugins: 0, + unhealthy_plugins: 0, + total_requests: 0, + total_success: 0, + total_failed: 0, + total_rate_limit_rejections: 0, + }; + + for entry in plugins.values() { + match entry.health_status { + PluginHealthStatus::Healthy => summary.healthy_plugins += 1, + PluginHealthStatus::Degraded => summary.degraded_plugins += 1, + PluginHealthStatus::Unhealthy => summary.unhealthy_plugins += 1, + PluginHealthStatus::Unknown => {} + } + + summary.total_requests += entry.counters.requests_total.load(Ordering::Relaxed); + summary.total_success += entry.counters.requests_success.load(Ordering::Relaxed); + summary.total_failed += entry.counters.requests_failed.load(Ordering::Relaxed); + summary.total_rate_limit_rejections += + entry.counters.rate_limit_rejections.load(Ordering::Relaxed); + } + + summary + } + + /// Clear all metrics (useful for testing) + #[cfg(test)] + pub async fn clear(&self) { + let mut plugins = self.plugins.write().await; + plugins.clear(); + } + + /// Remove metrics for a specific plugin + pub async fn remove_plugin(&self, plugin_id: Uuid) { + let mut plugins = self.plugins.write().await; + plugins.remove(&plugin_id); + } + + /// Calculate health status based on counters + fn calculate_health( + &self, + counters: &PluginCounters, + last_failure: Option>, + ) -> PluginHealthStatus { + let total = counters.requests_total.load(Ordering::Relaxed); + let failed = counters.requests_failed.load(Ordering::Relaxed); + + if total == 0 { + return PluginHealthStatus::Unknown; + } + + let error_rate = failed as f64 / total as f64; + + // Check for recent failures (within last 5 minutes) + let recent_failure = last_failure.is_some_and(|t| (Utc::now() - t).num_minutes() < 5); + + if error_rate > 0.5 || (recent_failure && error_rate > 0.2) { + PluginHealthStatus::Unhealthy + } else if error_rate > 0.1 || recent_failure { + PluginHealthStatus::Degraded + } else { + PluginHealthStatus::Healthy + } + } + + /// Build a snapshot from an entry + fn build_snapshot(&self, entry: &PluginMetricsEntry) -> PluginMetricsSnapshot { + let requests_total = entry.counters.requests_total.load(Ordering::Relaxed); + let requests_success = entry.counters.requests_success.load(Ordering::Relaxed); + let requests_failed = entry.counters.requests_failed.load(Ordering::Relaxed); + let total_duration_ms = entry.counters.total_duration_ms.load(Ordering::Relaxed); + + let avg_duration_ms = if requests_total > 0 { + total_duration_ms as f64 / requests_total as f64 + } else { + 0.0 + }; + + let error_rate_pct = if requests_total > 0 { + (requests_failed as f64 / requests_total as f64) * 100.0 + } else { + 0.0 + }; + + // Build per-method metrics + let by_method: HashMap = entry + .method_counters + .by_method + .iter() + .map(|(method, counters)| { + let total = counters.requests_total.load(Ordering::Relaxed); + let duration = counters.total_duration_ms.load(Ordering::Relaxed); + ( + method.clone(), + MethodMetrics { + method: method.clone(), + requests_total: total, + requests_success: counters.requests_success.load(Ordering::Relaxed), + requests_failed: counters.requests_failed.load(Ordering::Relaxed), + avg_duration_ms: if total > 0 { + duration as f64 / total as f64 + } else { + 0.0 + }, + }, + ) + }) + .collect(); + + // Count failures by error code + let mut failure_counts: HashMap = HashMap::new(); + for failure in &entry.recent_failures { + *failure_counts + .entry(failure.error_code.clone()) + .or_insert(0) += 1; + } + + PluginMetricsSnapshot { + plugin_id: entry.plugin_id, + plugin_name: entry.plugin_name.clone(), + requests_total, + requests_success, + requests_failed, + avg_duration_ms, + rate_limit_rejections: entry.counters.rate_limit_rejections.load(Ordering::Relaxed), + error_rate_pct, + last_success: entry.last_success, + last_failure: entry.last_failure, + health_status: entry.health_status, + by_method, + failure_counts, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_record_success() { + let service = PluginMetricsService::new(); + let plugin_id = Uuid::new_v4(); + + service + .record_success(plugin_id, "test-plugin", "search", 100) + .await; + service + .record_success(plugin_id, "test-plugin", "search", 200) + .await; + + let metrics = service.get_plugin_metrics(plugin_id).await.unwrap(); + assert_eq!(metrics.requests_total, 2); + assert_eq!(metrics.requests_success, 2); + assert_eq!(metrics.requests_failed, 0); + assert_eq!(metrics.avg_duration_ms, 150.0); + assert_eq!(metrics.health_status, PluginHealthStatus::Healthy); + } + + #[tokio::test] + async fn test_record_failure() { + let service = PluginMetricsService::new(); + let plugin_id = Uuid::new_v4(); + + service + .record_failure(plugin_id, "test-plugin", "search", 100, Some("TIMEOUT")) + .await; + + let metrics = service.get_plugin_metrics(plugin_id).await.unwrap(); + assert_eq!(metrics.requests_total, 1); + assert_eq!(metrics.requests_success, 0); + assert_eq!(metrics.requests_failed, 1); + assert_eq!(metrics.error_rate_pct, 100.0); + assert_eq!(metrics.failure_counts.get("TIMEOUT"), Some(&1)); + } + + #[tokio::test] + async fn test_health_status_calculation() { + let service = PluginMetricsService::new(); + let plugin_id = Uuid::new_v4(); + + // Start with success + for _ in 0..10 { + service + .record_success(plugin_id, "test-plugin", "search", 100) + .await; + } + let metrics = service.get_plugin_metrics(plugin_id).await.unwrap(); + assert_eq!(metrics.health_status, PluginHealthStatus::Healthy); + + // Add some failures (>10% error rate = degraded) + for _ in 0..2 { + service + .record_failure(plugin_id, "test-plugin", "search", 100, None) + .await; + } + let metrics = service.get_plugin_metrics(plugin_id).await.unwrap(); + assert_eq!(metrics.health_status, PluginHealthStatus::Degraded); + + // Add many more failures (>50% error rate = unhealthy) + for _ in 0..10 { + service + .record_failure(plugin_id, "test-plugin", "search", 100, None) + .await; + } + let metrics = service.get_plugin_metrics(plugin_id).await.unwrap(); + assert_eq!(metrics.health_status, PluginHealthStatus::Unhealthy); + } + + #[tokio::test] + async fn test_rate_limit_recording() { + let service = PluginMetricsService::new(); + let plugin_id = Uuid::new_v4(); + + service.record_rate_limit(plugin_id, "test-plugin").await; + service.record_rate_limit(plugin_id, "test-plugin").await; + + let metrics = service.get_plugin_metrics(plugin_id).await.unwrap(); + assert_eq!(metrics.rate_limit_rejections, 2); + } + + #[tokio::test] + async fn test_per_method_metrics() { + let service = PluginMetricsService::new(); + let plugin_id = Uuid::new_v4(); + + service + .record_success(plugin_id, "test-plugin", "search", 100) + .await; + service + .record_success(plugin_id, "test-plugin", "search", 200) + .await; + service + .record_success(plugin_id, "test-plugin", "get_metadata", 50) + .await; + service + .record_failure(plugin_id, "test-plugin", "get_metadata", 300, None) + .await; + + let metrics = service.get_plugin_metrics(plugin_id).await.unwrap(); + + let search_metrics = metrics.by_method.get("search").unwrap(); + assert_eq!(search_metrics.requests_total, 2); + assert_eq!(search_metrics.requests_success, 2); + assert_eq!(search_metrics.avg_duration_ms, 150.0); + + let get_metadata_metrics = metrics.by_method.get("get_metadata").unwrap(); + assert_eq!(get_metadata_metrics.requests_total, 2); + assert_eq!(get_metadata_metrics.requests_success, 1); + assert_eq!(get_metadata_metrics.requests_failed, 1); + } + + #[tokio::test] + async fn test_summary() { + let service = PluginMetricsService::new(); + + let plugin1 = Uuid::new_v4(); + let plugin2 = Uuid::new_v4(); + + // Plugin 1: healthy + for _ in 0..10 { + service + .record_success(plugin1, "plugin-1", "search", 100) + .await; + } + + // Plugin 2: unhealthy (all failures) + for _ in 0..5 { + service + .record_failure(plugin2, "plugin-2", "search", 100, Some("ERROR")) + .await; + } + + let summary = service.get_summary().await; + assert_eq!(summary.total_plugins, 2); + assert_eq!(summary.healthy_plugins, 1); + assert_eq!(summary.unhealthy_plugins, 1); + assert_eq!(summary.total_requests, 15); + assert_eq!(summary.total_success, 10); + assert_eq!(summary.total_failed, 5); + } + + #[tokio::test] + async fn test_clear() { + let service = PluginMetricsService::new(); + let plugin_id = Uuid::new_v4(); + + service + .record_success(plugin_id, "test-plugin", "search", 100) + .await; + assert!(service.get_plugin_metrics(plugin_id).await.is_some()); + + service.clear().await; + assert!(service.get_plugin_metrics(plugin_id).await.is_none()); + } + + #[tokio::test] + async fn test_remove_plugin() { + let service = PluginMetricsService::new(); + let plugin1 = Uuid::new_v4(); + let plugin2 = Uuid::new_v4(); + + service + .record_success(plugin1, "plugin-1", "search", 100) + .await; + service + .record_success(plugin2, "plugin-2", "search", 100) + .await; + + service.remove_plugin(plugin1).await; + + assert!(service.get_plugin_metrics(plugin1).await.is_none()); + assert!(service.get_plugin_metrics(plugin2).await.is_some()); + } +} diff --git a/src/tasks/handlers/generate_series_thumbnail.rs b/src/tasks/handlers/generate_series_thumbnail.rs index d0a5fe83..929db6be 100644 --- a/src/tasks/handlers/generate_series_thumbnail.rs +++ b/src/tasks/handlers/generate_series_thumbnail.rs @@ -1,6 +1,7 @@ //! Handler for GenerateSeriesThumbnail task //! -//! Generates a thumbnail for a series using the first book's cover. +//! Generates a thumbnail for a series using the selected cover from series_covers, +//! or falls back to the first book's cover if no cover is selected. use anyhow::{anyhow, Result}; use sea_orm::DatabaseConnection; @@ -8,7 +9,7 @@ use std::sync::Arc; use tracing::{debug, info, warn}; use crate::db::entities::tasks; -use crate::db::repositories::{BookRepository, SeriesRepository}; +use crate::db::repositories::{BookRepository, SeriesCoversRepository, SeriesRepository}; use crate::events::{EntityChangeEvent, EntityEvent, EntityType, EventBroadcaster}; use crate::services::ThumbnailService; use crate::tasks::handlers::TaskHandler; @@ -87,7 +88,80 @@ impl TaskHandler for GenerateSeriesThumbnailHandler { } } - // Get the first book in the series to generate thumbnail from + // First, check if there's a selected cover in series_covers + if let Ok(Some(selected_cover)) = + SeriesCoversRepository::get_selected(db, series_id).await + { + debug!( + "Found selected cover for series {}: source={}", + series_id, selected_cover.source + ); + + // Read the cover image file + match tokio::fs::read(&selected_cover.path).await { + Ok(image_data) => { + // Generate thumbnail from the selected cover + match self + .thumbnail_service + .generate_thumbnail_from_image(db, image_data) + .await + { + Ok(thumbnail_data) => { + // Save series thumbnail + match self + .thumbnail_service + .save_series_thumbnail(series_id, &thumbnail_data) + .await + { + Ok(path) => { + info!( + "Task {}: Generated series thumbnail from selected cover ({}) at {:?}", + task.id, selected_cover.source, path + ); + + // Emit CoverUpdated event for series + emit_series_cover_updated( + event_broadcaster, + series_id, + series.library_id, + ); + + return Ok(TaskResult::success_with_data( + format!("Generated thumbnail for series {}", series_id), + serde_json::json!({ + "series_id": series_id, + "source": selected_cover.source, + "path": path.to_string_lossy(), + "force": force, + }), + )); + } + Err(e) => { + warn!( + "Failed to save series thumbnail from selected cover: {}", + e + ); + // Fall through to book-based thumbnail + } + } + } + Err(e) => { + warn!("Failed to generate thumbnail from selected cover: {}", e); + // Fall through to book-based thumbnail + } + } + } + Err(e) => { + warn!( + "Failed to read selected cover file at {}: {}", + selected_cover.path, e + ); + // Fall through to book-based thumbnail + } + } + } + + // No selected cover or failed to use it - fall back to first book's cover let first_book = BookRepository::get_first_in_series(db, series_id) .await? .ok_or_else(|| anyhow!("Series {} has no books", series_id))?; diff --git a/src/tasks/handlers/generate_series_thumbnails.rs b/src/tasks/handlers/generate_series_thumbnails.rs new file mode 100644 index 00000000..d9aa4cf7 --- /dev/null +++ b/src/tasks/handlers/generate_series_thumbnails.rs @@ -0,0 +1,156 @@ +//! Handler for GenerateSeriesThumbnails task (fan-out) +//! +//! Generates thumbnails for all series in a scope (library or all). +//! This is a fan-out task that enqueues individual GenerateSeriesThumbnail tasks +//! for each series that needs a thumbnail. + +use anyhow::Result; +use sea_orm::DatabaseConnection; +use std::sync::Arc; +use tracing::{debug, info, warn}; + +use crate::db::entities::tasks; +use crate::db::repositories::{SeriesRepository, TaskRepository}; +use crate::events::EventBroadcaster; +use crate::services::ThumbnailService; +use crate::tasks::handlers::TaskHandler; +use crate::tasks::types::{TaskResult, TaskType}; + +pub struct GenerateSeriesThumbnailsHandler { + thumbnail_service: Arc, +} + +impl GenerateSeriesThumbnailsHandler { + pub fn new(thumbnail_service: Arc) -> Self { + Self { thumbnail_service } + } +} + +impl TaskHandler for GenerateSeriesThumbnailsHandler { + fn handle<'a>( + &'a self, + task: &'a tasks::Model, + db: &'a DatabaseConnection, + _event_broadcaster: Option<&'a Arc>, + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + info!( + "Task {}: Starting batch series thumbnail generation (fan-out)", + task.id + ); + + // Extract parameters from task + let library_id = task.library_id; + let force = task + .params + .as_ref() + .and_then(|p| p.get("force")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + // Get series based on scope + let series_list = if let Some(lib_id) = library_id { + info!( + "Generating series thumbnails for library {} (force={})", + lib_id, force + ); + SeriesRepository::list_by_library(db, lib_id).await? + } else { + info!( + "Generating series thumbnails for all series (force={})", + force + ); + SeriesRepository::list_all(db).await? + }; + + let total = series_list.len(); + info!("Found {} series to process", total); + + // Filter series if not forcing - only include series without thumbnails + let series_to_process: Vec<_> = if force { + series_list + } else { + let mut filtered = Vec::new(); + for series in series_list { + if self + .thumbnail_service + .get_series_thumbnail_metadata(series.id) + .await + .is_none() + { + filtered.push(series); + } + } + filtered + }; + + let to_process = series_to_process.len(); + let skipped = total - to_process; + + if skipped > 0 { + info!("Skipping {} series that already have thumbnails", skipped); + } + + if to_process == 0 { + info!("No series need thumbnail generation"); + return Ok(TaskResult::success_with_data( + "No series need thumbnail generation".to_string(), + serde_json::json!({ + "total": total, + "enqueued": 0, + "skipped": skipped, + }), + )); + } + + // Enqueue individual GenerateSeriesThumbnail tasks for each series + let mut enqueued = 0; + let mut errors = Vec::new(); + + for series in series_to_process { + let task_type = TaskType::GenerateSeriesThumbnail { + series_id: series.id, + force, + }; + + match TaskRepository::enqueue(db, task_type, 0, None).await { + Ok(task_id) => { + debug!( + "Enqueued series thumbnail task {} for series {} (force={})", + task_id, series.id, force + ); + enqueued += 1; + } + Err(e) => { + let error_msg = format!( + "Failed to enqueue series thumbnail task for series {}: {}", + series.id, e + ); + warn!("{}", error_msg); + errors.push(error_msg); + } + } + } + + info!( + "Batch series thumbnail generation complete: enqueued {} tasks ({} skipped, {} errors)", + enqueued, + skipped, + errors.len() + ); + + Ok(TaskResult::success_with_data( + format!( + "Enqueued {} series thumbnail tasks ({} skipped)", + enqueued, skipped + ), + serde_json::json!({ + "total": total, + "enqueued": enqueued, + "skipped": skipped, + "errors": errors, + }), + )) + }) + } +} diff --git a/src/tasks/handlers/mod.rs b/src/tasks/handlers/mod.rs index 9d09ef8f..d4d53ec8 100644 --- a/src/tasks/handlers/mod.rs +++ b/src/tasks/handlers/mod.rs @@ -14,8 +14,10 @@ pub mod cleanup_pdf_cache; pub mod cleanup_series_files; pub mod find_duplicates; pub mod generate_series_thumbnail; +pub mod generate_series_thumbnails; pub mod generate_thumbnail; pub mod generate_thumbnails; +pub mod plugin_auto_match; pub mod purge_deleted; pub mod scan_library; @@ -27,8 +29,10 @@ pub use cleanup_pdf_cache::CleanupPdfCacheHandler; pub use cleanup_series_files::CleanupSeriesFilesHandler; pub use find_duplicates::FindDuplicatesHandler; pub use generate_series_thumbnail::GenerateSeriesThumbnailHandler; +pub use generate_series_thumbnails::GenerateSeriesThumbnailsHandler; pub use generate_thumbnail::GenerateThumbnailHandler; pub use generate_thumbnails::GenerateThumbnailsHandler; +pub use plugin_auto_match::PluginAutoMatchHandler; pub use purge_deleted::PurgeDeletedHandler; pub use scan_library::ScanLibraryHandler; diff --git a/src/tasks/handlers/plugin_auto_match.rs b/src/tasks/handlers/plugin_auto_match.rs new file mode 100644 index 00000000..7dd97089 --- /dev/null +++ b/src/tasks/handlers/plugin_auto_match.rs @@ -0,0 +1,420 @@ +//! Plugin auto-match task handler +//! +//! This handler processes plugin auto-match tasks, which search for metadata +//! using a plugin and apply the best match to a series. + +use anyhow::{Context, Result}; +use sea_orm::DatabaseConnection; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::sync::Arc; +use tracing::{debug, info}; +use uuid::Uuid; + +use crate::db::entities::tasks; +use crate::db::repositories::{PluginsRepository, SeriesMetadataRepository, SeriesRepository}; +use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; +use crate::services::metadata::{ApplyOptions, MetadataApplier, SkippedField}; +use crate::services::plugin::protocol::{MetadataGetParams, MetadataSearchParams}; +use crate::services::plugin::PluginManager; +use crate::services::settings::SettingsService; +use crate::services::ThumbnailService; +use crate::tasks::handlers::TaskHandler; +use crate::tasks::types::TaskResult; + +/// Settings key for the auto-match confidence threshold +const SETTING_AUTO_MATCH_CONFIDENCE_THRESHOLD: &str = "plugins.auto_match_confidence_threshold"; +/// Default confidence threshold for auto-match (0.8 = 80%) +const DEFAULT_CONFIDENCE_THRESHOLD: f64 = 0.8; + +/// Result of a plugin auto-match operation +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginAutoMatchResult { + pub matched: bool, + pub external_id: Option, + pub external_url: Option, + pub matched_title: Option, + pub fields_updated: Vec, + pub fields_skipped: Vec, + pub skipped_reason: Option, +} + +/// Handler for plugin auto-match tasks +pub struct PluginAutoMatchHandler { + plugin_manager: Arc, + thumbnail_service: Option>, + settings_service: Option>, +} + +impl PluginAutoMatchHandler { + pub fn new(plugin_manager: Arc) -> Self { + Self { + plugin_manager, + thumbnail_service: None, + settings_service: None, + } + } + + pub fn with_thumbnail_service(mut self, thumbnail_service: Arc) -> Self { + self.thumbnail_service = Some(thumbnail_service); + self + } + + pub fn with_settings_service(mut self, settings_service: Arc) -> Self { + self.settings_service = Some(settings_service); + self + } +} + +impl TaskHandler for PluginAutoMatchHandler { + fn handle<'a>( + &'a self, + task: &'a tasks::Model, + db: &'a DatabaseConnection, + event_broadcaster: Option<&'a Arc>, + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + // Extract task parameters + let series_id = task + .series_id + .ok_or_else(|| anyhow::anyhow!("Missing series_id in task"))?; + + let params = task + .params + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Missing params in task"))?; + + let plugin_id: Uuid = params + .get("plugin_id") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()) + .ok_or_else(|| anyhow::anyhow!("Missing or invalid plugin_id in params"))?; + + let source_scope = params + .get("source_scope") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + info!( + "Task {}: Auto-matching series {} with plugin {} (source: {:?})", + task.id, series_id, plugin_id, source_scope + ); + + // Check if plugin is enabled + let plugin = match PluginsRepository::get_by_id(db, plugin_id).await? { + Some(p) => p, + None => { + return Ok(TaskResult::success_with_data( + "Plugin not found, skipped", + json!(PluginAutoMatchResult { + matched: false, + external_id: None, + external_url: None, + matched_title: None, + fields_updated: vec![], + fields_skipped: vec![], + skipped_reason: Some("plugin_not_found".to_string()), + }), + )); + } + }; + + if !plugin.enabled { + return Ok(TaskResult::success_with_data( + "Plugin disabled, skipped", + json!(PluginAutoMatchResult { + matched: false, + external_id: None, + external_url: None, + matched_title: None, + fields_updated: vec![], + fields_skipped: vec![], + skipped_reason: Some("plugin_disabled".to_string()), + }), + )); + } + + // Get series and its metadata for the search query + let series = SeriesRepository::get_by_id(db, series_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Series not found: {}", series_id))?; + + let series_metadata = SeriesMetadataRepository::get_by_series_id(db, series_id).await?; + + let search_query = series_metadata + .as_ref() + .map(|m| m.title.clone()) + .unwrap_or_else(|| series.name.clone()); + + debug!( + "Task {}: Searching for '{}' using plugin {}", + task.id, search_query, plugin.name + ); + + // Search for metadata + let search_params = MetadataSearchParams { + query: search_query.clone(), + limit: Some(10), + cursor: None, + }; + + let search_response = self + .plugin_manager + .search_series(plugin_id, search_params) + .await + .context("Failed to search for metadata")?; + + if search_response.results.is_empty() { + info!("Task {}: No matches found for '{}'", task.id, search_query); + return Ok(TaskResult::success_with_data( + format!("No matches found for '{}'", search_query), + json!(PluginAutoMatchResult { + matched: false, + external_id: None, + external_url: None, + matched_title: None, + fields_updated: vec![], + fields_skipped: vec![], + skipped_reason: Some("no_match".to_string()), + }), + )); + } + + // Pick the best result based on relevance_score + let best_match = search_response + .results + .into_iter() + .enumerate() + .max_by(|(i, a), (j, b)| { + match (a.relevance_score, b.relevance_score) { + (Some(a_score), Some(b_score)) => a_score + .partial_cmp(&b_score) + .unwrap_or(std::cmp::Ordering::Equal), + // If no scores, prefer earlier results (lower index = higher relevance) + _ => j.cmp(i), + } + }) + .map(|(_, result)| result) + .unwrap(); // Safe: we checked results is non-empty + + // Get confidence threshold from settings (fallback to default if not available) + let min_confidence = if let Some(ref settings) = self.settings_service { + settings + .get_float( + SETTING_AUTO_MATCH_CONFIDENCE_THRESHOLD, + DEFAULT_CONFIDENCE_THRESHOLD, + ) + .await + .unwrap_or(DEFAULT_CONFIDENCE_THRESHOLD) + } else { + DEFAULT_CONFIDENCE_THRESHOLD + }; + + // Check confidence threshold + // Only skip if the plugin provides a relevance score AND it's below the threshold + // If no relevance score is provided, we proceed with the match (to support plugins + // that don't return relevance scores) + if let Some(relevance_score) = best_match.relevance_score { + if relevance_score < min_confidence { + info!( + "Task {}: Best match '{}' has low confidence ({:.2} < {:.2}), skipping", + task.id, best_match.title, relevance_score, min_confidence + ); + return Ok(TaskResult::success_with_data( + format!( + "Low confidence match ({:.0}% < {:.0}%), skipped", + relevance_score * 100.0, + min_confidence * 100.0 + ), + json!(PluginAutoMatchResult { + matched: false, + external_id: Some(best_match.external_id.clone()), + external_url: None, // Not available from search results + matched_title: Some(best_match.title.clone()), + fields_updated: vec![], + fields_skipped: vec![], + skipped_reason: Some("low_confidence".to_string()), + }), + )); + } + } + + let external_id = best_match.external_id.clone(); + let matched_title = best_match.title.clone(); + + info!( + "Task {}: Best match: '{}' (id: {})", + task.id, matched_title, external_id + ); + + // Fetch full metadata + let get_params = MetadataGetParams { + external_id: external_id.clone(), + }; + + let plugin_metadata = self + .plugin_manager + .get_series_metadata(plugin_id, get_params) + .await + .context("Failed to fetch full metadata")?; + + let external_url = plugin_metadata.external_url.clone(); + + // Get current metadata for lock checking + let current_metadata = + SeriesMetadataRepository::get_by_series_id(db, series_id).await?; + + // Build apply options with thumbnail service and event broadcaster + let options = ApplyOptions { + fields_filter: None, // Apply all fields + thumbnail_service: self.thumbnail_service.clone(), + event_broadcaster: event_broadcaster.cloned(), + }; + + // Apply metadata using the shared service + let result = MetadataApplier::apply( + db, + series_id, + series.library_id, + &plugin, + &plugin_metadata, + current_metadata.as_ref(), + &options, + ) + .await + .context("Failed to apply metadata")?; + + let applied_fields = result.applied_fields; + let skipped_fields = result.skipped_fields; + + info!( + "Task {}: Applied {} fields, skipped {} fields", + task.id, + applied_fields.len(), + skipped_fields.len() + ); + + // Emit series metadata updated event + if let Some(broadcaster) = event_broadcaster { + if !applied_fields.is_empty() { + let _ = broadcaster.emit(EntityChangeEvent::new( + EntityEvent::SeriesMetadataUpdated { + series_id, + library_id: series.library_id, + plugin_id, + fields_updated: applied_fields.clone(), + }, + None, + )); + } + } + + // Record success with plugin + if let Err(e) = PluginsRepository::record_success(db, plugin_id).await { + tracing::warn!("Failed to record plugin success: {}", e); + } + + let result = PluginAutoMatchResult { + matched: !applied_fields.is_empty(), + external_id: Some(external_id), + external_url: Some(external_url), + matched_title: Some(matched_title.clone()), + fields_updated: applied_fields.clone(), + fields_skipped: skipped_fields, + skipped_reason: None, + }; + + let message = if applied_fields.is_empty() { + format!("Matched '{}' but no fields were applied", matched_title) + } else { + format!( + "Matched '{}' and applied {} field(s)", + matched_title, + applied_fields.len() + ) + }; + + Ok(TaskResult::success_with_data(message, json!(result))) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_plugin_auto_match_result_serialization() { + let result = PluginAutoMatchResult { + matched: true, + external_id: Some("12345".to_string()), + external_url: Some("https://example.com/series/12345".to_string()), + matched_title: Some("Test Series".to_string()), + fields_updated: vec!["title".to_string(), "summary".to_string()], + fields_skipped: vec![SkippedField { + field: "genres".to_string(), + reason: "Plugin does not have permission".to_string(), + }], + skipped_reason: None, + }; + + let json = serde_json::to_value(&result).unwrap(); + assert_eq!(json["matched"], true); + assert_eq!(json["externalId"], "12345"); + assert_eq!(json["fieldsUpdated"].as_array().unwrap().len(), 2); + } + + #[test] + fn test_skipped_result() { + let result = PluginAutoMatchResult { + matched: false, + external_id: None, + external_url: None, + matched_title: None, + fields_updated: vec![], + fields_skipped: vec![], + skipped_reason: Some("plugin_disabled".to_string()), + }; + + let json = serde_json::to_value(&result).unwrap(); + assert_eq!(json["matched"], false); + assert_eq!(json["skippedReason"], "plugin_disabled"); + } + + #[test] + fn test_low_confidence_result() { + // Low confidence result should include the matched info (external_id, title) + // but not external_url since that's not available from search results + let result = PluginAutoMatchResult { + matched: false, + external_id: Some("12345".to_string()), + external_url: None, // Not available from search results + matched_title: Some("Test Series".to_string()), + fields_updated: vec![], + fields_skipped: vec![], + skipped_reason: Some("low_confidence".to_string()), + }; + + let json = serde_json::to_value(&result).unwrap(); + assert_eq!(json["matched"], false); + assert_eq!(json["skippedReason"], "low_confidence"); + // Low confidence should still include the matched info + assert_eq!(json["externalId"], "12345"); + assert_eq!(json["matchedTitle"], "Test Series"); + assert!(json["externalUrl"].is_null()); + } + + #[test] + fn test_default_confidence_threshold() { + assert_eq!(DEFAULT_CONFIDENCE_THRESHOLD, 0.8); + } + + #[test] + fn test_setting_key() { + assert_eq!( + SETTING_AUTO_MATCH_CONFIDENCE_THRESHOLD, + "plugins.auto_match_confidence_threshold" + ); + } +} diff --git a/src/tasks/handlers/scan_library.rs b/src/tasks/handlers/scan_library.rs index 951f616e..e9945306 100644 --- a/src/tasks/handlers/scan_library.rs +++ b/src/tasks/handlers/scan_library.rs @@ -5,13 +5,24 @@ use std::sync::Arc; use tracing::{debug, error, info, warn}; use crate::db::entities::tasks; -use crate::db::repositories::{BookRepository, LibraryRepository}; +use crate::db::repositories::{ + BookRepository, LibraryRepository, PluginsRepository, SeriesRepository, TaskRepository, +}; use crate::events::EventBroadcaster; use crate::scanner::{scan_library, ScanMode, ScanningConfig}; +use crate::services::plugin::protocol::PluginScope; +use crate::services::settings::SettingsService; use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::TaskResult; +use crate::tasks::types::{TaskResult, TaskType}; -pub struct ScanLibraryHandler; +/// Settings key for enabling post-scan auto-match +const SETTING_POST_SCAN_AUTO_MATCH_ENABLED: &str = "plugins.post_scan_auto_match_enabled"; +/// Default value for post-scan auto-match (disabled for safety) +const DEFAULT_POST_SCAN_AUTO_MATCH_ENABLED: bool = false; + +pub struct ScanLibraryHandler { + settings_service: Option>, +} impl Default for ScanLibraryHandler { fn default() -> Self { @@ -21,7 +32,153 @@ impl Default for ScanLibraryHandler { impl ScanLibraryHandler { pub fn new() -> Self { - Self + Self { + settings_service: None, + } + } + + /// Enable post-scan auto-match by providing a settings service + pub fn with_settings_service(mut self, settings_service: Arc) -> Self { + self.settings_service = Some(settings_service); + self + } + + /// Queue plugin auto-match tasks for all series in the library + /// + /// This is called after a library scan completes. It: + /// 1. Checks if the feature is enabled via settings + /// 2. Finds all plugins with `library:scan` scope that apply to this library + /// 3. Gets all series in the library + /// 4. Enqueues auto-match tasks for each series/plugin combination + /// + /// Returns the number of tasks queued (0 if feature is disabled or no applicable plugins). + async fn queue_post_scan_auto_match( + &self, + db: &DatabaseConnection, + task_id: uuid::Uuid, + library_id: uuid::Uuid, + ) -> usize { + // Check if feature is enabled via settings + let is_enabled = if let Some(ref settings) = self.settings_service { + settings + .get_bool( + SETTING_POST_SCAN_AUTO_MATCH_ENABLED, + DEFAULT_POST_SCAN_AUTO_MATCH_ENABLED, + ) + .await + .unwrap_or(DEFAULT_POST_SCAN_AUTO_MATCH_ENABLED) + } else { + debug!( + "Task {}: SettingsService not available, post-scan auto-match disabled", + task_id + ); + return 0; + }; + + if !is_enabled { + debug!( + "Task {}: Post-scan auto-match is disabled via settings", + task_id + ); + return 0; + } + + // Find plugins with library:scan scope that apply to this library + let plugins = match PluginsRepository::get_enabled_by_scope_and_library( + db, + &PluginScope::LibraryScan, + library_id, + ) + .await + { + Ok(plugins) => plugins, + Err(e) => { + warn!( + "Task {}: Failed to query plugins for post-scan auto-match: {}", + task_id, e + ); + return 0; + } + }; + + if plugins.is_empty() { + debug!( + "Task {}: No plugins with library:scan scope found for library {}", + task_id, library_id + ); + return 0; + } + + info!( + "Task {}: Found {} plugin(s) with library:scan scope for library {}", + task_id, + plugins.len(), + library_id + ); + + // Get all series in the library + let series_list = match SeriesRepository::list_by_library(db, library_id).await { + Ok(series) => series, + Err(e) => { + warn!( + "Task {}: Failed to list series for post-scan auto-match: {}", + task_id, e + ); + return 0; + } + }; + + if series_list.is_empty() { + debug!( + "Task {}: No series found in library {} for post-scan auto-match", + task_id, library_id + ); + return 0; + } + + info!( + "Task {}: Queueing auto-match tasks for {} series with {} plugin(s)", + task_id, + series_list.len(), + plugins.len() + ); + + // Enqueue auto-match tasks for each series/plugin combination + let mut tasks_queued = 0; + for series in &series_list { + for plugin in &plugins { + match TaskRepository::enqueue( + db, + TaskType::PluginAutoMatch { + series_id: series.id, + plugin_id: plugin.id, + source_scope: Some("library:scan".to_string()), + }, + 0, // priority (normal) + None, // schedule now + ) + .await + { + Ok(_) => tasks_queued += 1, + Err(e) => { + // Log but don't fail - other tasks may succeed + warn!( + "Task {}: Failed to enqueue auto-match task for series {} with plugin {}: {}", + task_id, series.id, plugin.id, e + ); + } + } + } + } + + if tasks_queued > 0 { + info!( + "Task {}: Queued {} auto-match tasks for post-scan processing", + task_id, tasks_queued + ); + } + + tasks_queued } } @@ -139,9 +296,15 @@ impl TaskHandler for ScanLibraryHandler { } }; + // Post-scan auto-match: Queue plugin auto-match tasks for series + // if the feature is enabled and there are plugins with library:scan scope + let auto_match_tasks_queued = self + .queue_post_scan_auto_match(db, task.id, library_id) + .await; + Ok(TaskResult::success_with_data( format!( - "Scanned {} files ({} series, {} books), queued {} analysis tasks{}", + "Scanned {} files ({} series, {} books), queued {} analysis tasks{}{}", result.files_processed, result.series_created, result.books_created, @@ -150,6 +313,11 @@ impl TaskHandler for ScanLibraryHandler { format!(", purged {} deleted books", purged_count) } else { String::new() + }, + if auto_match_tasks_queued > 0 { + format!(", queued {} auto-match tasks", auto_match_tasks_queued) + } else { + String::new() } ), json!({ @@ -161,6 +329,7 @@ impl TaskHandler for ScanLibraryHandler { "books_restored": result.books_restored, "tasks_queued": result.tasks_queued, "books_purged": purged_count, + "auto_match_tasks_queued": auto_match_tasks_queued, "errors": result.errors.len(), }), )) @@ -173,3 +342,29 @@ impl TaskHandler for ScanLibraryHandler { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_setting_constants() { + assert_eq!( + SETTING_POST_SCAN_AUTO_MATCH_ENABLED, + "plugins.post_scan_auto_match_enabled" + ); + const { assert!(!DEFAULT_POST_SCAN_AUTO_MATCH_ENABLED) }; + } + + #[test] + fn test_handler_creation() { + let handler = ScanLibraryHandler::new(); + assert!(handler.settings_service.is_none()); + } + + #[test] + fn test_handler_default() { + let handler = ScanLibraryHandler::default(); + assert!(handler.settings_service.is_none()); + } +} diff --git a/src/tasks/types.rs b/src/tasks/types.rs index 3e2a7461..208c8381 100644 --- a/src/tasks/types.rs +++ b/src/tasks/types.rs @@ -61,6 +61,14 @@ pub enum TaskType { force: bool, // If true, regenerate even if thumbnail exists }, + /// Generate thumbnails for series in a scope (library or all) + /// This is a fan-out task that enqueues individual GenerateSeriesThumbnail tasks + GenerateSeriesThumbnails { + library_id: Option, // If set, only series in this library + #[serde(default)] + force: bool, // If true, regenerate all thumbnails; if false, only missing ones + }, + /// Find and catalog duplicate books across all libraries FindDuplicates, @@ -83,6 +91,15 @@ pub enum TaskType { /// Clean up old pages from the PDF page cache CleanupPdfCache, + + /// Auto-match metadata for a series using a plugin + PluginAutoMatch { + series_id: Uuid, + plugin_id: Uuid, + /// Source scope that triggered this task (for tracking) + #[serde(default)] + source_scope: Option, // "series:detail", "series:bulk", "library:detail", "library:scan" + }, } fn default_mode() -> String { @@ -101,11 +118,13 @@ impl TaskType { TaskType::GenerateThumbnails { .. } => "generate_thumbnails", TaskType::GenerateThumbnail { .. } => "generate_thumbnail", TaskType::GenerateSeriesThumbnail { .. } => "generate_series_thumbnail", + TaskType::GenerateSeriesThumbnails { .. } => "generate_series_thumbnails", TaskType::FindDuplicates => "find_duplicates", TaskType::CleanupBookFiles { .. } => "cleanup_book_files", TaskType::CleanupSeriesFiles { .. } => "cleanup_series_files", TaskType::CleanupOrphanedFiles => "cleanup_orphaned_files", TaskType::CleanupPdfCache => "cleanup_pdf_cache", + TaskType::PluginAutoMatch { .. } => "plugin_auto_match", } } @@ -115,6 +134,7 @@ impl TaskType { TaskType::ScanLibrary { library_id, .. } => Some(*library_id), TaskType::PurgeDeleted { library_id } => Some(*library_id), TaskType::GenerateThumbnails { library_id, .. } => *library_id, + TaskType::GenerateSeriesThumbnails { library_id, .. } => *library_id, _ => None, } } @@ -143,6 +163,9 @@ impl TaskType { TaskType::GenerateSeriesThumbnail { force, .. } => { serde_json::json!({ "force": force }) } + TaskType::GenerateSeriesThumbnails { force, .. } => { + serde_json::json!({ "force": force }) + } TaskType::CleanupBookFiles { book_id, thumbnail_path, @@ -155,6 +178,13 @@ impl TaskType { // Store series_id in params since the FK column can't reference deleted series serde_json::json!({ "series_id": series_id }) } + TaskType::PluginAutoMatch { + plugin_id, + source_scope, + .. + } => { + serde_json::json!({ "plugin_id": plugin_id, "source_scope": source_scope }) + } _ => serde_json::json!({}), } } @@ -166,6 +196,7 @@ impl TaskType { TaskType::AnalyzeSeries { series_id, .. } => Some(*series_id), TaskType::GenerateThumbnails { series_id, .. } => *series_id, TaskType::GenerateSeriesThumbnail { series_id, .. } => Some(*series_id), + TaskType::PluginAutoMatch { series_id, .. } => Some(*series_id), // CleanupSeriesFiles intentionally NOT included - series_id is stored in params // because the series may already be deleted when the task runs _ => None, @@ -460,6 +491,53 @@ mod tests { assert_eq!(params.unwrap()["force"], true); } + #[test] + fn test_generate_series_thumbnails_extraction() { + let library_id = Uuid::new_v4(); + + // Library scope + let task = TaskType::GenerateSeriesThumbnails { + library_id: Some(library_id), + force: false, + }; + assert_eq!(task.type_string(), "generate_series_thumbnails"); + assert_eq!(task.library_id(), Some(library_id)); + assert_eq!(task.series_id(), None); + assert_eq!(task.book_id(), None); + + let params = task.params(); + assert_eq!(params["force"], false); + + // All scope + let task = TaskType::GenerateSeriesThumbnails { + library_id: None, + force: true, + }; + assert_eq!(task.library_id(), None); + assert_eq!(task.series_id(), None); + + let params = task.params(); + assert_eq!(params["force"], true); + } + + #[test] + fn test_generate_series_thumbnails_extract_fields() { + let library_id = Uuid::new_v4(); + + let task = TaskType::GenerateSeriesThumbnails { + library_id: Some(library_id), + force: true, + }; + + let (type_str, lib_id, series_id, book_id, params) = task.extract_fields(); + assert_eq!(type_str, "generate_series_thumbnails"); + assert_eq!(lib_id, Some(library_id)); + assert_eq!(series_id, None); + assert_eq!(book_id, None); + assert!(params.is_some()); + assert_eq!(params.unwrap()["force"], true); + } + #[test] fn test_cleanup_book_files_extraction() { let book_id = Uuid::new_v4(); diff --git a/src/tasks/worker.rs b/src/tasks/worker.rs index 28c70c75..6f0e37de 100644 --- a/src/tasks/worker.rs +++ b/src/tasks/worker.rs @@ -19,13 +19,15 @@ use uuid::Uuid; use crate::config::FilesConfig; use crate::db::repositories::TaskRepository; use crate::events::{EventBroadcaster, RecordedEvent, TaskProgressEvent}; +use crate::services::plugin::PluginManager; use crate::services::PdfPageCache; use crate::services::{SettingsService, TaskMetricsService, ThumbnailService}; use crate::tasks::handlers::{ AnalyzeBookHandler, AnalyzeSeriesHandler, CleanupBookFilesHandler, CleanupOrphanedFilesHandler, CleanupPdfCacheHandler, CleanupSeriesFilesHandler, FindDuplicatesHandler, - GenerateSeriesThumbnailHandler, GenerateThumbnailHandler, GenerateThumbnailsHandler, - PurgeDeletedHandler, ScanLibraryHandler, TaskHandler, + GenerateSeriesThumbnailHandler, GenerateSeriesThumbnailsHandler, GenerateThumbnailHandler, + GenerateThumbnailsHandler, PluginAutoMatchHandler, PurgeDeletedHandler, ScanLibraryHandler, + TaskHandler, }; /// Task worker that processes tasks from the queue @@ -38,6 +40,7 @@ pub struct TaskWorker { settings_service: Option>, thumbnail_service: Option>, task_metrics_service: Option>, + plugin_manager: Option>, shutdown_tx: Option>, } @@ -83,6 +86,7 @@ impl TaskWorker { settings_service: None, thumbnail_service: None, task_metrics_service: None, + plugin_manager: None, shutdown_tx: None, } } @@ -106,7 +110,15 @@ impl TaskWorker { } /// Set the settings service for runtime configuration + /// + /// This also registers/updates handlers that depend on settings: + /// - `ScanLibraryHandler` for post-scan auto-match settings pub fn with_settings_service(mut self, settings_service: Arc) -> Self { + // Re-register ScanLibraryHandler with settings service for post-scan auto-match + self.handlers.insert( + "scan_library".to_string(), + Arc::new(ScanLibraryHandler::new().with_settings_service(settings_service.clone())), + ); self.settings_service = Some(settings_service); self } @@ -130,6 +142,13 @@ impl TaskWorker { thumbnail_service.clone(), )), ); + // Register the GenerateSeriesThumbnailsHandler (batch/fan-out) with thumbnail service + self.handlers.insert( + "generate_series_thumbnails".to_string(), + Arc::new(GenerateSeriesThumbnailsHandler::new( + thumbnail_service.clone(), + )), + ); self.thumbnail_service = Some(thumbnail_service); self } @@ -143,6 +162,38 @@ impl TaskWorker { self } + /// Set the plugin manager for plugin auto-match tasks + /// + /// This registers the `plugin_auto_match` task handler that enables + /// background metadata matching via plugins. + /// + /// **Note**: Call `with_thumbnail_service` and `with_settings_service` before this method so that + /// `PluginAutoMatchHandler` can download/apply cover images and respect confidence threshold settings. + pub fn with_plugin_manager(mut self, plugin_manager: Arc) -> Self { + // Register the PluginAutoMatchHandler with ThumbnailService and SettingsService if available + let mut handler = PluginAutoMatchHandler::new(plugin_manager.clone()); + if let Some(ref thumbnail_service) = self.thumbnail_service { + handler = handler.with_thumbnail_service(thumbnail_service.clone()); + } else { + tracing::warn!( + "ThumbnailService not set - PluginAutoMatchHandler will not download covers. \ + Call with_thumbnail_service before with_plugin_manager." + ); + } + if let Some(ref settings_service) = self.settings_service { + handler = handler.with_settings_service(settings_service.clone()); + } else { + tracing::warn!( + "SettingsService not set - PluginAutoMatchHandler will use default confidence threshold. \ + Call with_settings_service before with_plugin_manager." + ); + } + self.handlers + .insert("plugin_auto_match".to_string(), Arc::new(handler)); + self.plugin_manager = Some(plugin_manager); + self + } + /// Set the files config for cleanup handlers /// /// This registers the cleanup task handlers that need access to diff --git a/tests/api.rs b/tests/api.rs index 70e6a82d..f27a853b 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -9,6 +9,7 @@ mod api { mod api_keys; mod auth; mod books; + mod bulk_operations; mod covers; mod duplicates; mod external_links; @@ -24,6 +25,8 @@ mod api { mod opds2; mod pages; mod pdf_cache; + mod plugin_metrics; + mod plugins; mod rate_limit; mod read_progress; mod scan; diff --git a/tests/api/books.rs b/tests/api/books.rs index 012df376..43acc189 100644 --- a/tests/api/books.rs +++ b/tests/api/books.rs @@ -1056,416 +1056,6 @@ async fn test_books_filename_fallback_with_multiple_extensions() { assert_eq!(returned_book.title, "book.vol.1"); } -// ============================================================================ -// Books With Errors Tests -// ============================================================================ - -// Helper to create a test book with an analysis error -// Note: title is now in book_metadata table, not books table -fn create_test_book_with_error( - series_id: uuid::Uuid, - library_id: uuid::Uuid, - path: &str, - name: &str, - error: &str, -) -> codex::db::entities::books::Model { - use chrono::Utc; - codex::db::entities::books::Model { - id: uuid::Uuid::new_v4(), - series_id, - library_id, - file_path: path.to_string(), - file_name: name.to_string(), - file_size: 1024, - file_hash: format!("hash_{}", uuid::Uuid::new_v4()), - partial_hash: String::new(), - format: "cbz".to_string(), - page_count: 0, - deleted: false, - analyzed: false, - analysis_error: Some(error.to_string()), - analysis_errors: None, - modified_at: Utc::now(), - created_at: Utc::now(), - updated_at: Utc::now(), - thumbnail_path: None, - thumbnail_generated_at: None, - } -} - -#[tokio::test] -async fn test_list_books_with_errors() { - let (db, _temp_dir) = setup_test_db().await; - - // Create library and series - let library = - LibraryRepository::create(&db, "Test Library", "/test", ScanningStrategy::Default) - .await - .unwrap(); - - let series = SeriesRepository::create(&db, library.id, "Test Series", None) - .await - .unwrap(); - - // Create 2 books without errors - for i in 1..=2 { - let book = create_test_book_model( - series.id, - library.id, - &format!("/test/good{}.cbz", i), - &format!("good{}.cbz", i), - Some(format!("Good Book {}", i)), - ); - BookRepository::create(&db, &book, None).await.unwrap(); - } - - // Create 3 books with errors - for i in 1..=3 { - let book = create_test_book_with_error( - series.id, - library.id, - &format!("/test/bad{}.cbz", i), - &format!("bad{}.cbz", i), - &format!("Failed to parse CBZ: invalid archive {}", i), - ); - BookRepository::create(&db, &book, None).await.unwrap(); - } - - let state = create_test_auth_state(db.clone()).await; - let token = create_admin_and_token(&db, &state).await; - let app = create_test_router(state).await; - - // Request books with errors - let request = get_request_with_auth("/api/v1/books/with-errors", &token); - let (status, response): (StatusCode, Option) = - make_json_request(app, request).await; - - assert_eq!(status, StatusCode::OK); - let book_list = response.unwrap(); - assert_eq!(book_list.data.len(), 3); - assert_eq!(book_list.total, 3); - - // Verify all returned books have analysis errors - for book in &book_list.data { - assert!(book.analysis_error.is_some()); - assert!(book - .analysis_error - .as_ref() - .unwrap() - .contains("Failed to parse CBZ")); - } -} - -#[tokio::test] -async fn test_list_books_with_errors_empty() { - let (db, _temp_dir) = setup_test_db().await; - - // Create library and series - let library = - LibraryRepository::create(&db, "Test Library", "/test", ScanningStrategy::Default) - .await - .unwrap(); - - let series = SeriesRepository::create(&db, library.id, "Test Series", None) - .await - .unwrap(); - - // Create books without errors - for i in 1..=3 { - let book = create_test_book_model( - series.id, - library.id, - &format!("/test/book{}.cbz", i), - &format!("book{}.cbz", i), - Some(format!("Book {}", i)), - ); - BookRepository::create(&db, &book, None).await.unwrap(); - } - - let state = create_test_auth_state(db.clone()).await; - let token = create_admin_and_token(&db, &state).await; - let app = create_test_router(state).await; - - // Request books with errors (should be empty) - let request = get_request_with_auth("/api/v1/books/with-errors", &token); - let (status, response): (StatusCode, Option) = - make_json_request(app, request).await; - - assert_eq!(status, StatusCode::OK); - let book_list = response.unwrap(); - assert_eq!(book_list.data.len(), 0); - assert_eq!(book_list.total, 0); -} - -#[tokio::test] -async fn test_list_library_books_with_errors() { - let (db, _temp_dir) = setup_test_db().await; - - // Create two libraries - let library1 = LibraryRepository::create(&db, "Library 1", "/test1", ScanningStrategy::Default) - .await - .unwrap(); - let library2 = LibraryRepository::create(&db, "Library 2", "/test2", ScanningStrategy::Default) - .await - .unwrap(); - - let series1 = SeriesRepository::create(&db, library1.id, "Series 1", None) - .await - .unwrap(); - let series2 = SeriesRepository::create(&db, library2.id, "Series 2", None) - .await - .unwrap(); - - // Create 2 books with errors in library1 - for i in 1..=2 { - let book = create_test_book_with_error( - series1.id, - library1.id, - &format!("/test1/bad{}.cbz", i), - &format!("bad{}.cbz", i), - &format!("Error in library 1: {}", i), - ); - BookRepository::create(&db, &book, None).await.unwrap(); - } - - // Create 3 books with errors in library2 - for i in 1..=3 { - let book = create_test_book_with_error( - series2.id, - library2.id, - &format!("/test2/bad{}.cbz", i), - &format!("bad{}.cbz", i), - &format!("Error in library 2: {}", i), - ); - BookRepository::create(&db, &book, None).await.unwrap(); - } - - let state = create_test_auth_state(db.clone()).await; - let token = create_admin_and_token(&db, &state).await; - let app = create_test_router(state).await; - - // Request books with errors for library1 - let request = get_request_with_auth( - &format!("/api/v1/libraries/{}/books/with-errors", library1.id), - &token, - ); - let (status, response): (StatusCode, Option) = - make_json_request(app, request).await; - - assert_eq!(status, StatusCode::OK); - let book_list = response.unwrap(); - assert_eq!(book_list.data.len(), 2); - assert_eq!(book_list.total, 2); - - // Verify all returned books are from library1 (via series1) and have errors - for book in &book_list.data { - assert_eq!(book.series_id, series1.id); - assert!(book.analysis_error.is_some()); - assert!(book - .analysis_error - .as_ref() - .unwrap() - .contains("Error in library 1")); - } -} - -#[tokio::test] -async fn test_list_library_books_with_errors_nonexistent_library() { - let (db, _temp_dir) = setup_test_db().await; - - let state = create_test_auth_state(db.clone()).await; - let token = create_admin_and_token(&db, &state).await; - let app = create_test_router(state).await; - - // Request books with errors for non-existent library - // API returns 200 with empty list (consistent with list_library_books behavior) - let fake_id = uuid::Uuid::new_v4(); - let request = get_request_with_auth( - &format!("/api/v1/libraries/{}/books/with-errors", fake_id), - &token, - ); - let (status, response): (StatusCode, Option) = - make_json_request(app, request).await; - - assert_eq!(status, StatusCode::OK); - let book_list = response.unwrap(); - assert_eq!(book_list.data.len(), 0); - assert_eq!(book_list.total, 0); -} - -#[tokio::test] -async fn test_list_series_books_with_errors() { - let (db, _temp_dir) = setup_test_db().await; - - // Create library with two series - let library = - LibraryRepository::create(&db, "Test Library", "/test", ScanningStrategy::Default) - .await - .unwrap(); - - let series1 = SeriesRepository::create(&db, library.id, "Series 1", None) - .await - .unwrap(); - let series2 = SeriesRepository::create(&db, library.id, "Series 2", None) - .await - .unwrap(); - - // Create 2 books with errors in series1 - for i in 1..=2 { - let book = create_test_book_with_error( - series1.id, - library.id, - &format!("/test/series1/bad{}.cbz", i), - &format!("bad{}.cbz", i), - &format!("Error in series 1: {}", i), - ); - BookRepository::create(&db, &book, None).await.unwrap(); - } - - // Create 1 good book in series1 - let good_book = create_test_book_model( - series1.id, - library.id, - "/test/series1/good.cbz", - "good.cbz", - Some("Good Book".to_string()), - ); - BookRepository::create(&db, &good_book, None).await.unwrap(); - - // Create 3 books with errors in series2 - for i in 1..=3 { - let book = create_test_book_with_error( - series2.id, - library.id, - &format!("/test/series2/bad{}.cbz", i), - &format!("bad{}.cbz", i), - &format!("Error in series 2: {}", i), - ); - BookRepository::create(&db, &book, None).await.unwrap(); - } - - let state = create_test_auth_state(db.clone()).await; - let token = create_admin_and_token(&db, &state).await; - let app = create_test_router(state).await; - - // Request books with errors for series1 - let request = get_request_with_auth( - &format!("/api/v1/series/{}/books/with-errors", series1.id), - &token, - ); - let (status, response): (StatusCode, Option) = - make_json_request(app, request).await; - - assert_eq!(status, StatusCode::OK); - let book_list = response.unwrap(); - assert_eq!(book_list.data.len(), 2); - assert_eq!(book_list.total, 2); - - // Verify all returned books are from series1 and have errors - for book in &book_list.data { - assert_eq!(book.series_id, series1.id); - assert!(book.analysis_error.is_some()); - assert!(book - .analysis_error - .as_ref() - .unwrap() - .contains("Error in series 1")); - } -} - -#[tokio::test] -async fn test_list_series_books_with_errors_nonexistent_series() { - let (db, _temp_dir) = setup_test_db().await; - - let state = create_test_auth_state(db.clone()).await; - let token = create_admin_and_token(&db, &state).await; - let app = create_test_router(state).await; - - // Request books with errors for non-existent series - // API returns 200 with empty list (consistent with list_series_books behavior) - let fake_id = uuid::Uuid::new_v4(); - let request = get_request_with_auth( - &format!("/api/v1/series/{}/books/with-errors", fake_id), - &token, - ); - let (status, response): (StatusCode, Option) = - make_json_request(app, request).await; - - assert_eq!(status, StatusCode::OK); - let book_list = response.unwrap(); - assert_eq!(book_list.data.len(), 0); - assert_eq!(book_list.total, 0); -} - -#[tokio::test] -async fn test_list_books_with_errors_pagination() { - let (db, _temp_dir) = setup_test_db().await; - - // Create library and series - let library = - LibraryRepository::create(&db, "Test Library", "/test", ScanningStrategy::Default) - .await - .unwrap(); - - let series = SeriesRepository::create(&db, library.id, "Test Series", None) - .await - .unwrap(); - - // Create 15 books with errors - for i in 1..=15 { - let book = create_test_book_with_error( - series.id, - library.id, - &format!("/test/bad{:02}.cbz", i), - &format!("bad{:02}.cbz", i), - &format!("Error {}", i), - ); - BookRepository::create(&db, &book, None).await.unwrap(); - } - - let state = create_test_auth_state(db.clone()).await; - let token = create_admin_and_token(&db, &state).await; - let app = create_test_router(state).await; - - // Request first page (10 items) - pages are 1-indexed - let request = get_request_with_auth("/api/v1/books/with-errors?page=1&pageSize=10", &token); - let (status, response): (StatusCode, Option) = - make_json_request(app, request).await; - - assert_eq!(status, StatusCode::OK); - let book_list = response.unwrap(); - assert_eq!(book_list.data.len(), 10); - assert_eq!(book_list.total, 15); - assert_eq!(book_list.page, 1); - assert_eq!(book_list.page_size, 10); - - // Request second page - let app2 = create_test_router(create_test_auth_state(db.clone()).await).await; - let request = get_request_with_auth("/api/v1/books/with-errors?page=2&pageSize=10", &token); - let (status, response): (StatusCode, Option) = - make_json_request(app2, request).await; - - assert_eq!(status, StatusCode::OK); - let book_list = response.unwrap(); - assert_eq!(book_list.data.len(), 5); - assert_eq!(book_list.total, 15); - assert_eq!(book_list.page, 2); -} - -#[tokio::test] -async fn test_list_books_with_errors_requires_auth() { - let (db, _temp_dir) = setup_test_db().await; - - let state = create_test_auth_state(db.clone()).await; - let app = create_test_router(state).await; - - // Request without auth token - let request = get_request("/api/v1/books/with-errors"); - let (status, _): (StatusCode, Option) = make_json_request(app, request).await; - - assert_eq!(status, StatusCode::UNAUTHORIZED); -} - // ============================================================================ // Recently Read Books Tests // ============================================================================ @@ -3089,7 +2679,7 @@ use codex::api::routes::v1::dto::book::{ use codex::db::entities::book_error::{BookError, BookErrorType}; /// Helper to create a book with structured errors (v2 - using analysis_errors JSON field) -async fn create_test_book_with_error_v2( +async fn create_test_book_with_error( db: &sea_orm::DatabaseConnection, series_id: uuid::Uuid, library_id: uuid::Uuid, @@ -3131,7 +2721,7 @@ async fn test_list_books_with_errors_v2() { .unwrap(); // Create books with different error types - let book1 = create_test_book_with_error_v2( + let book1 = create_test_book_with_error( &db, series.id, library.id, @@ -3142,7 +2732,7 @@ async fn test_list_books_with_errors_v2() { ) .await; - let book2 = create_test_book_with_error_v2( + let book2 = create_test_book_with_error( &db, series.id, library.id, @@ -3213,7 +2803,7 @@ async fn test_list_books_with_errors_v2_filter_by_type() { .unwrap(); // Create books with different error types - let _book1 = create_test_book_with_error_v2( + let _book1 = create_test_book_with_error( &db, series.id, library.id, @@ -3224,7 +2814,7 @@ async fn test_list_books_with_errors_v2_filter_by_type() { ) .await; - let book2 = create_test_book_with_error_v2( + let book2 = create_test_book_with_error( &db, series.id, library.id, @@ -3275,7 +2865,7 @@ async fn test_retry_book_errors() { .unwrap(); // Create a book with parser error - let book = create_test_book_with_error_v2( + let book = create_test_book_with_error( &db, series.id, library.id, @@ -3423,7 +3013,7 @@ async fn test_retry_all_book_errors() { .unwrap(); // Create multiple books with errors - let _book1 = create_test_book_with_error_v2( + let _book1 = create_test_book_with_error( &db, series.id, library.id, @@ -3434,7 +3024,7 @@ async fn test_retry_all_book_errors() { ) .await; - let _book2 = create_test_book_with_error_v2( + let _book2 = create_test_book_with_error( &db, series.id, library.id, @@ -3481,7 +3071,7 @@ async fn test_retry_all_book_errors_filter_by_type() { .unwrap(); // Create multiple books with errors - let _book1 = create_test_book_with_error_v2( + let _book1 = create_test_book_with_error( &db, series.id, library.id, @@ -3492,7 +3082,7 @@ async fn test_retry_all_book_errors_filter_by_type() { ) .await; - let _book2 = create_test_book_with_error_v2( + let _book2 = create_test_book_with_error( &db, series.id, library.id, @@ -3543,7 +3133,7 @@ async fn test_list_books_with_errors_v2_single_error_type() { .unwrap(); // Create multiple books with only parser errors (single error type) - let book1 = create_test_book_with_error_v2( + let book1 = create_test_book_with_error( &db, series.id, library.id, @@ -3554,7 +3144,7 @@ async fn test_list_books_with_errors_v2_single_error_type() { ) .await; - let book2 = create_test_book_with_error_v2( + let book2 = create_test_book_with_error( &db, series.id, library.id, @@ -3627,7 +3217,7 @@ async fn test_list_books_with_errors_v2_long_error_message() { ); // Create a book with a very long error message - let book = create_test_book_with_error_v2( + let book = create_test_book_with_error( &db, series.id, library.id, @@ -3867,7 +3457,7 @@ async fn test_retry_all_book_errors_filter_by_library() { .unwrap(); // Create errored books in both libraries - let _book1 = create_test_book_with_error_v2( + let _book1 = create_test_book_with_error( &db, series1.id, library1.id, @@ -3878,7 +3468,7 @@ async fn test_retry_all_book_errors_filter_by_library() { ) .await; - let _book2 = create_test_book_with_error_v2( + let _book2 = create_test_book_with_error( &db, series2.id, library2.id, @@ -3926,7 +3516,7 @@ async fn test_list_books_with_errors_v2_pagination() { // Create 25 books with errors to test pagination (default page size is usually 20-50) for i in 0..25 { - let _book = create_test_book_with_error_v2( + let _book = create_test_book_with_error( &db, series.id, library.id, @@ -4013,7 +3603,7 @@ async fn test_list_books_with_errors_v2_all_error_types() { ]; for (i, (error_type, message)) in error_types.iter().enumerate() { - let _book = create_test_book_with_error_v2( + let _book = create_test_book_with_error( &db, series.id, library.id, diff --git a/tests/api/bulk_operations.rs b/tests/api/bulk_operations.rs new file mode 100644 index 00000000..0ff19128 --- /dev/null +++ b/tests/api/bulk_operations.rs @@ -0,0 +1,633 @@ +//! Tests for bulk operations endpoints +//! +//! Tests bulk mark read/unread and analyze operations for books and series. + +#[path = "../common/mod.rs"] +mod common; + +use codex::api::routes::v1::dto::{ + BulkAnalyzeBooksRequest, BulkAnalyzeResponse, BulkAnalyzeSeriesRequest, BulkBooksRequest, + BulkSeriesRequest, MarkReadResponse, +}; +use codex::db::repositories::{ + BookRepository, LibraryRepository, ReadProgressRepository, SeriesRepository, UserRepository, +}; +use codex::db::ScanningStrategy; +use codex::utils::password; +use common::*; +use hyper::StatusCode; + +// Helper to create admin and token +async fn create_admin_and_token( + db: &sea_orm::DatabaseConnection, + state: &codex::api::extractors::AuthState, +) -> (uuid::Uuid, String) { + let password_hash = password::hash_password("admin123").unwrap(); + let user = create_test_user("admin", "admin@example.com", &password_hash, true); + let created = UserRepository::create(db, &user).await.unwrap(); + let token = state + .jwt_service + .generate_token(created.id, created.username.clone(), created.get_role()) + .unwrap(); + (created.id, token) +} + +// Helper to create a test book model +fn create_test_book_model( + series_id: uuid::Uuid, + library_id: uuid::Uuid, + path: &str, + name: &str, + page_count: i32, +) -> codex::db::entities::books::Model { + use chrono::Utc; + codex::db::entities::books::Model { + id: uuid::Uuid::new_v4(), + series_id, + library_id, + file_path: path.to_string(), + file_name: name.to_string(), + file_size: 1024, + file_hash: format!("hash_{}", uuid::Uuid::new_v4()), + partial_hash: String::new(), + format: "cbz".to_string(), + page_count, + deleted: false, + analyzed: false, + analysis_error: None, + analysis_errors: None, + modified_at: Utc::now(), + created_at: Utc::now(), + updated_at: Utc::now(), + thumbnail_path: None, + thumbnail_generated_at: None, + } +} + +// ============================================================================ +// Bulk Mark Books as Read Tests +// ============================================================================ + +#[tokio::test] +async fn test_bulk_mark_books_as_read() { + let (db, _temp_dir) = setup_test_db().await; + + // Create library and series + let library = + LibraryRepository::create(&db, "Test Library", "/test", ScanningStrategy::Default) + .await + .unwrap(); + + let series = SeriesRepository::create(&db, library.id, "Test Series", None) + .await + .unwrap(); + + // Create 3 test books + let mut book_ids = Vec::new(); + for i in 1..=3 { + let book = create_test_book_model( + series.id, + library.id, + &format!("/test/book{}.cbz", i), + &format!("book{}.cbz", i), + 50, + ); + let book = BookRepository::create(&db, &book, None).await.unwrap(); + book_ids.push(book.id); + } + + let state = create_test_auth_state(db.clone()).await; + let (user_id, token) = create_admin_and_token(&db, &state).await; + let app = create_test_router(state).await; + + // Bulk mark books as read + let request_body = BulkBooksRequest { + book_ids: book_ids.clone(), + }; + let request = post_json_request_with_auth("/api/v1/books/bulk/read", &request_body, &token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let mark_response = response.unwrap(); + assert_eq!(mark_response.count, 3); + assert!(mark_response.message.contains("3 books")); + + // Verify all books are marked as read + for book_id in book_ids { + let progress = ReadProgressRepository::get_by_user_and_book(&db, user_id, book_id) + .await + .unwrap() + .unwrap(); + assert!(progress.completed); + assert_eq!(progress.current_page, 50); + } +} + +#[tokio::test] +async fn test_bulk_mark_books_as_read_empty_list() { + let (db, _temp_dir) = setup_test_db().await; + + let state = create_test_auth_state(db.clone()).await; + let (_user_id, token) = create_admin_and_token(&db, &state).await; + let app = create_test_router(state).await; + + // Bulk mark empty list as read + let request_body = BulkBooksRequest { book_ids: vec![] }; + let request = post_json_request_with_auth("/api/v1/books/bulk/read", &request_body, &token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let mark_response = response.unwrap(); + assert_eq!(mark_response.count, 0); + assert!(mark_response.message.contains("No books")); +} + +#[tokio::test] +async fn test_bulk_mark_books_as_read_with_invalid_ids() { + let (db, _temp_dir) = setup_test_db().await; + + // Create library and series + let library = + LibraryRepository::create(&db, "Test Library", "/test", ScanningStrategy::Default) + .await + .unwrap(); + + let series = SeriesRepository::create(&db, library.id, "Test Series", None) + .await + .unwrap(); + + // Create 1 real book + let book = create_test_book_model(series.id, library.id, "/test/book1.cbz", "book1.cbz", 50); + let book = BookRepository::create(&db, &book, None).await.unwrap(); + + let state = create_test_auth_state(db.clone()).await; + let (user_id, token) = create_admin_and_token(&db, &state).await; + let app = create_test_router(state).await; + + // Include real book and non-existent book IDs + let request_body = BulkBooksRequest { + book_ids: vec![book.id, uuid::Uuid::new_v4(), uuid::Uuid::new_v4()], + }; + let request = post_json_request_with_auth("/api/v1/books/bulk/read", &request_body, &token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let mark_response = response.unwrap(); + // Only the real book should be marked + assert_eq!(mark_response.count, 1); + + // Verify only the real book is marked as read + let progress = ReadProgressRepository::get_by_user_and_book(&db, user_id, book.id) + .await + .unwrap() + .unwrap(); + assert!(progress.completed); +} + +// ============================================================================ +// Bulk Mark Books as Unread Tests +// ============================================================================ + +#[tokio::test] +async fn test_bulk_mark_books_as_unread() { + let (db, _temp_dir) = setup_test_db().await; + + // Create library and series + let library = + LibraryRepository::create(&db, "Test Library", "/test", ScanningStrategy::Default) + .await + .unwrap(); + + let series = SeriesRepository::create(&db, library.id, "Test Series", None) + .await + .unwrap(); + + // Create 3 test books + let mut book_ids = Vec::new(); + for i in 1..=3 { + let book = create_test_book_model( + series.id, + library.id, + &format!("/test/book{}.cbz", i), + &format!("book{}.cbz", i), + 50, + ); + let book = BookRepository::create(&db, &book, None).await.unwrap(); + book_ids.push(book.id); + } + + let state = create_test_auth_state(db.clone()).await; + let (user_id, token) = create_admin_and_token(&db, &state).await; + + // Create progress for all books + for book_id in &book_ids { + ReadProgressRepository::upsert(&db, user_id, *book_id, 25, false) + .await + .unwrap(); + } + + let app = create_test_router(state).await; + + // Bulk mark books as unread + let request_body = BulkBooksRequest { + book_ids: book_ids.clone(), + }; + let request = post_json_request_with_auth("/api/v1/books/bulk/unread", &request_body, &token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let mark_response = response.unwrap(); + assert_eq!(mark_response.count, 3); + assert!(mark_response.message.contains("3 books")); + + // Verify all progress is deleted + for book_id in book_ids { + let progress = ReadProgressRepository::get_by_user_and_book(&db, user_id, book_id) + .await + .unwrap(); + assert!(progress.is_none()); + } +} + +// ============================================================================ +// Bulk Analyze Books Tests +// ============================================================================ + +#[tokio::test] +async fn test_bulk_analyze_books() { + let (db, _temp_dir) = setup_test_db().await; + + // Create library and series + let library = + LibraryRepository::create(&db, "Test Library", "/test", ScanningStrategy::Default) + .await + .unwrap(); + + let series = SeriesRepository::create(&db, library.id, "Test Series", None) + .await + .unwrap(); + + // Create 3 test books + let mut book_ids = Vec::new(); + for i in 1..=3 { + let book = create_test_book_model( + series.id, + library.id, + &format!("/test/book{}.cbz", i), + &format!("book{}.cbz", i), + 50, + ); + let book = BookRepository::create(&db, &book, None).await.unwrap(); + book_ids.push(book.id); + } + + let state = create_test_auth_state(db.clone()).await; + let (_user_id, token) = create_admin_and_token(&db, &state).await; + let app = create_test_router(state).await; + + // Bulk analyze books + let request_body = BulkAnalyzeBooksRequest { + book_ids: book_ids.clone(), + force: true, + }; + let request = post_json_request_with_auth("/api/v1/books/bulk/analyze", &request_body, &token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let analyze_response = response.unwrap(); + assert_eq!(analyze_response.tasks_enqueued, 3); + assert!(analyze_response.message.contains("3 analysis tasks")); +} + +#[tokio::test] +async fn test_bulk_analyze_books_empty_list() { + let (db, _temp_dir) = setup_test_db().await; + + let state = create_test_auth_state(db.clone()).await; + let (_user_id, token) = create_admin_and_token(&db, &state).await; + let app = create_test_router(state).await; + + // Bulk analyze empty list + let request_body = BulkAnalyzeBooksRequest { + book_ids: vec![], + force: false, + }; + let request = post_json_request_with_auth("/api/v1/books/bulk/analyze", &request_body, &token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let analyze_response = response.unwrap(); + assert_eq!(analyze_response.tasks_enqueued, 0); + assert!(analyze_response.message.contains("No books")); +} + +// ============================================================================ +// Bulk Mark Series as Read Tests +// ============================================================================ + +#[tokio::test] +async fn test_bulk_mark_series_as_read() { + let (db, _temp_dir) = setup_test_db().await; + + // Create library and series + let library = + LibraryRepository::create(&db, "Test Library", "/test", ScanningStrategy::Default) + .await + .unwrap(); + + // Create 2 series with books + let mut series_ids = Vec::new(); + let mut total_books = 0; + for s in 1..=2 { + let series = SeriesRepository::create(&db, library.id, &format!("Test Series {}", s), None) + .await + .unwrap(); + series_ids.push(series.id); + + // Create 3 books per series + for i in 1..=3 { + let book = create_test_book_model( + series.id, + library.id, + &format!("/test/series{}/book{}.cbz", s, i), + &format!("book{}.cbz", i), + 50, + ); + BookRepository::create(&db, &book, None).await.unwrap(); + total_books += 1; + } + } + + let state = create_test_auth_state(db.clone()).await; + let (_user_id, token) = create_admin_and_token(&db, &state).await; + let app = create_test_router(state).await; + + // Bulk mark series as read + let request_body = BulkSeriesRequest { + series_ids: series_ids.clone(), + }; + let request = post_json_request_with_auth("/api/v1/series/bulk/read", &request_body, &token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let mark_response = response.unwrap(); + assert_eq!(mark_response.count, total_books); + assert!(mark_response + .message + .contains(&format!("{} books", total_books))); +} + +#[tokio::test] +async fn test_bulk_mark_series_as_read_empty_list() { + let (db, _temp_dir) = setup_test_db().await; + + let state = create_test_auth_state(db.clone()).await; + let (_user_id, token) = create_admin_and_token(&db, &state).await; + let app = create_test_router(state).await; + + // Bulk mark empty series list as read + let request_body = BulkSeriesRequest { series_ids: vec![] }; + let request = post_json_request_with_auth("/api/v1/series/bulk/read", &request_body, &token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let mark_response = response.unwrap(); + assert_eq!(mark_response.count, 0); + assert!(mark_response.message.contains("No series")); +} + +// ============================================================================ +// Bulk Mark Series as Unread Tests +// ============================================================================ + +#[tokio::test] +async fn test_bulk_mark_series_as_unread() { + let (db, _temp_dir) = setup_test_db().await; + + // Create library and series + let library = + LibraryRepository::create(&db, "Test Library", "/test", ScanningStrategy::Default) + .await + .unwrap(); + + // Create 2 series with books + let mut series_ids = Vec::new(); + let mut all_book_ids = Vec::new(); + for s in 1..=2 { + let series = SeriesRepository::create(&db, library.id, &format!("Test Series {}", s), None) + .await + .unwrap(); + series_ids.push(series.id); + + // Create 3 books per series + for i in 1..=3 { + let book = create_test_book_model( + series.id, + library.id, + &format!("/test/series{}/book{}.cbz", s, i), + &format!("book{}.cbz", i), + 50, + ); + let book = BookRepository::create(&db, &book, None).await.unwrap(); + all_book_ids.push(book.id); + } + } + + let state = create_test_auth_state(db.clone()).await; + let (user_id, token) = create_admin_and_token(&db, &state).await; + + // Create progress for all books + for book_id in &all_book_ids { + ReadProgressRepository::upsert(&db, user_id, *book_id, 25, false) + .await + .unwrap(); + } + + let app = create_test_router(state).await; + + // Bulk mark series as unread + let request_body = BulkSeriesRequest { + series_ids: series_ids.clone(), + }; + let request = post_json_request_with_auth("/api/v1/series/bulk/unread", &request_body, &token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let mark_response = response.unwrap(); + assert_eq!(mark_response.count, 6); // 2 series * 3 books + assert!(mark_response.message.contains("6 books")); + + // Verify all progress is deleted + for book_id in all_book_ids { + let progress = ReadProgressRepository::get_by_user_and_book(&db, user_id, book_id) + .await + .unwrap(); + assert!(progress.is_none()); + } +} + +// ============================================================================ +// Bulk Analyze Series Tests +// ============================================================================ + +#[tokio::test] +async fn test_bulk_analyze_series() { + let (db, _temp_dir) = setup_test_db().await; + + // Create library and series + let library = + LibraryRepository::create(&db, "Test Library", "/test", ScanningStrategy::Default) + .await + .unwrap(); + + // Create 2 series with books + let mut series_ids = Vec::new(); + let mut total_books = 0; + for s in 1..=2 { + let series = SeriesRepository::create(&db, library.id, &format!("Test Series {}", s), None) + .await + .unwrap(); + series_ids.push(series.id); + + // Create 3 books per series + for i in 1..=3 { + let book = create_test_book_model( + series.id, + library.id, + &format!("/test/series{}/book{}.cbz", s, i), + &format!("book{}.cbz", i), + 50, + ); + BookRepository::create(&db, &book, None).await.unwrap(); + total_books += 1; + } + } + + let state = create_test_auth_state(db.clone()).await; + let (_user_id, token) = create_admin_and_token(&db, &state).await; + let app = create_test_router(state).await; + + // Bulk analyze series + let request_body = BulkAnalyzeSeriesRequest { + series_ids: series_ids.clone(), + force: true, + }; + let request = post_json_request_with_auth("/api/v1/series/bulk/analyze", &request_body, &token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let analyze_response = response.unwrap(); + assert_eq!(analyze_response.tasks_enqueued, total_books); + assert!(analyze_response + .message + .contains(&format!("{} analysis tasks", total_books))); +} + +#[tokio::test] +async fn test_bulk_analyze_series_empty_list() { + let (db, _temp_dir) = setup_test_db().await; + + let state = create_test_auth_state(db.clone()).await; + let (_user_id, token) = create_admin_and_token(&db, &state).await; + let app = create_test_router(state).await; + + // Bulk analyze empty series list + let request_body = BulkAnalyzeSeriesRequest { + series_ids: vec![], + force: false, + }; + let request = post_json_request_with_auth("/api/v1/series/bulk/analyze", &request_body, &token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let analyze_response = response.unwrap(); + assert_eq!(analyze_response.tasks_enqueued, 0); + assert!(analyze_response.message.contains("No series")); +} + +// ============================================================================ +// Authorization Tests +// ============================================================================ + +#[tokio::test] +async fn test_bulk_mark_books_as_read_unauthorized() { + let (db, _temp_dir) = setup_test_db().await; + + let state = create_test_auth_state(db.clone()).await; + let app = create_test_router(state).await; + + // Try to bulk mark books as read without auth + let request_body = BulkBooksRequest { + book_ids: vec![uuid::Uuid::new_v4()], + }; + let request = post_json_request("/api/v1/books/bulk/read", &request_body); + let (status, _): (StatusCode, Option) = make_json_request(app, request).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_bulk_mark_series_as_read_unauthorized() { + let (db, _temp_dir) = setup_test_db().await; + + let state = create_test_auth_state(db.clone()).await; + let app = create_test_router(state).await; + + // Try to bulk mark series as read without auth + let request_body = BulkSeriesRequest { + series_ids: vec![uuid::Uuid::new_v4()], + }; + let request = post_json_request("/api/v1/series/bulk/read", &request_body); + let (status, _): (StatusCode, Option) = make_json_request(app, request).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_bulk_analyze_books_unauthorized() { + let (db, _temp_dir) = setup_test_db().await; + + let state = create_test_auth_state(db.clone()).await; + let app = create_test_router(state).await; + + // Try to bulk analyze books without auth + let request_body = BulkAnalyzeBooksRequest { + book_ids: vec![uuid::Uuid::new_v4()], + force: false, + }; + let request = post_json_request("/api/v1/books/bulk/analyze", &request_body); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_bulk_analyze_series_unauthorized() { + let (db, _temp_dir) = setup_test_db().await; + + let state = create_test_auth_state(db.clone()).await; + let app = create_test_router(state).await; + + // Try to bulk analyze series without auth + let request_body = BulkAnalyzeSeriesRequest { + series_ids: vec![uuid::Uuid::new_v4()], + force: false, + }; + let request = post_json_request("/api/v1/series/bulk/analyze", &request_body); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); +} diff --git a/tests/api/pdf_cache.rs b/tests/api/pdf_cache.rs index a76c99ba..d4b083d3 100644 --- a/tests/api/pdf_cache.rs +++ b/tests/api/pdf_cache.rs @@ -14,8 +14,8 @@ use codex::db::repositories::UserRepository; use codex::events::EventBroadcaster; use codex::services::email::EmailService; use codex::services::{ - AuthTrackingService, FileCleanupService, InflightThumbnailTracker, PdfPageCache, - ReadProgressService, SettingsService, ThumbnailService, + plugin::PluginManager, AuthTrackingService, FileCleanupService, InflightThumbnailTracker, + PdfPageCache, PluginMetricsService, ReadProgressService, SettingsService, ThumbnailService, }; use codex::utils::jwt::JwtService; use codex::utils::password; @@ -62,6 +62,8 @@ async fn create_test_app_state_with_pdf_cache( // Create PDF page cache with cache ENABLED let pdf_page_cache = Arc::new(PdfPageCache::new(temp_dir.path(), true)); + let plugin_manager = Arc::new(PluginManager::with_defaults(Arc::new(db.clone()))); + let plugin_metrics_service = Arc::new(PluginMetricsService::new()); Arc::new(AppState { db, @@ -82,6 +84,8 @@ async fn create_test_app_state_with_pdf_cache( inflight_thumbnails: Arc::new(InflightThumbnailTracker::new()), user_auth_cache: Arc::new(UserAuthCache::new()), rate_limiter_service: None, + plugin_manager, + plugin_metrics_service, }) } diff --git a/tests/api/plugin_metrics.rs b/tests/api/plugin_metrics.rs new file mode 100644 index 00000000..4312ef51 --- /dev/null +++ b/tests/api/plugin_metrics.rs @@ -0,0 +1,169 @@ +//! Plugin metrics API endpoint tests + +#[path = "../common/mod.rs"] +mod common; + +use codex::api::routes::v1::dto::PluginMetricsResponse; +use common::db::setup_test_db; +use common::fixtures::create_test_user; +use common::http::{ + create_test_app_state, create_test_router_with_app_state, generate_test_token, get_request, + get_request_with_auth, make_json_request, +}; +use hyper::StatusCode; + +// ============================================================ +// GET /api/v1/metrics/plugins tests +// ============================================================ + +#[tokio::test] +async fn test_get_plugin_metrics_empty() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_app_state(db.clone()).await; + + // Create admin user and get token + let user = create_test_user( + "admin", + "admin@example.com", + &codex::utils::password::hash_password("admin123").unwrap(), + true, + ); + let created_user = codex::db::repositories::UserRepository::create(&db, &user) + .await + .unwrap(); + let token = generate_test_token(&state, &created_user); + + let app = create_test_router_with_app_state(state.clone()); + let request = get_request_with_auth("/api/v1/metrics/plugins", &token); + + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let response = response.expect("Response should be present"); + assert_eq!(response.summary.total_plugins, 0); + assert_eq!(response.summary.total_requests, 0); + assert!(response.plugins.is_empty()); +} + +#[tokio::test] +async fn test_get_plugin_metrics_with_data() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_app_state(db.clone()).await; + + // Create admin user and get token + let user = create_test_user( + "admin", + "admin@example.com", + &codex::utils::password::hash_password("admin123").unwrap(), + true, + ); + let created_user = codex::db::repositories::UserRepository::create(&db, &user) + .await + .unwrap(); + let token = generate_test_token(&state, &created_user); + + // Record some metrics + let plugin_id = uuid::Uuid::new_v4(); + state + .plugin_metrics_service + .record_success(plugin_id, "Test Plugin", "search", 100) + .await; + state + .plugin_metrics_service + .record_success(plugin_id, "Test Plugin", "search", 200) + .await; + state + .plugin_metrics_service + .record_failure( + plugin_id, + "Test Plugin", + "get_metadata", + 300, + Some("TIMEOUT"), + ) + .await; + + let app = create_test_router_with_app_state(state.clone()); + let request = get_request_with_auth("/api/v1/metrics/plugins", &token); + + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let response = response.expect("Response should be present"); + + // Check summary + assert_eq!(response.summary.total_plugins, 1); + assert_eq!(response.summary.total_requests, 3); + assert_eq!(response.summary.total_success, 2); + assert_eq!(response.summary.total_failed, 1); + + // Check individual plugin metrics + assert_eq!(response.plugins.len(), 1); + let plugin = &response.plugins[0]; + assert_eq!(plugin.plugin_id, plugin_id); + assert_eq!(plugin.plugin_name, "Test Plugin"); + assert_eq!(plugin.requests_total, 3); + assert_eq!(plugin.requests_success, 2); + assert_eq!(plugin.requests_failed, 1); + + // Check method breakdown + let by_method = plugin + .by_method + .as_ref() + .expect("Should have method breakdown"); + assert!(by_method.contains_key("search")); + assert!(by_method.contains_key("get_metadata")); + let search = by_method.get("search").unwrap(); + assert_eq!(search.requests_total, 2); + assert_eq!(search.requests_success, 2); + + // Check failure counts + let failures = plugin + .failure_counts + .as_ref() + .expect("Should have failure counts"); + assert_eq!(failures.get("TIMEOUT"), Some(&1)); +} + +#[tokio::test] +async fn test_get_plugin_metrics_requires_auth() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_app_state(db).await; + let app = create_test_router_with_app_state(state); + + let request = get_request("/api/v1/metrics/plugins"); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_get_plugin_metrics_allowed_for_reader() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_app_state(db.clone()).await; + + // Create regular user (Reader role) and get token + let user = create_test_user( + "reader", + "reader@example.com", + &codex::utils::password::hash_password("reader123").unwrap(), + false, // not admin = Reader role + ); + let created_user = codex::db::repositories::UserRepository::create(&db, &user) + .await + .unwrap(); + let token = generate_test_token(&state, &created_user); + + let app = create_test_router_with_app_state(state); + let request = get_request_with_auth("/api/v1/metrics/plugins", &token); + + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + // Reader should have libraries:read permission which is required + assert_eq!(status, StatusCode::OK); + assert!(response.is_some()); +} diff --git a/tests/api/plugins.rs b/tests/api/plugins.rs new file mode 100644 index 00000000..fd7e9269 --- /dev/null +++ b/tests/api/plugins.rs @@ -0,0 +1,1053 @@ +//! Plugin API endpoint tests +//! +//! Tests for the admin plugin management endpoints: +//! - GET /api/v1/admin/plugins - List all plugins +//! - POST /api/v1/admin/plugins - Create a new plugin +//! - GET /api/v1/admin/plugins/:id - Get a plugin by ID +//! - PATCH /api/v1/admin/plugins/:id - Update a plugin +//! - DELETE /api/v1/admin/plugins/:id - Delete a plugin +//! - POST /api/v1/admin/plugins/:id/enable - Enable a plugin +//! - POST /api/v1/admin/plugins/:id/disable - Disable a plugin +//! - POST /api/v1/admin/plugins/:id/test - Test a plugin connection +//! - GET /api/v1/admin/plugins/:id/health - Get plugin health +//! - POST /api/v1/admin/plugins/:id/reset - Reset plugin failure count + +#[path = "../common/mod.rs"] +mod common; + +use common::db::setup_test_db; +use common::fixtures::create_test_user; +use common::http::{ + create_test_auth_state, create_test_router, delete_request_with_auth, generate_test_token, + get_request_with_auth, make_json_request, patch_json_request_with_auth, + post_json_request_with_auth, post_request_with_auth, +}; +use hyper::StatusCode; +use serde_json::json; + +use codex::api::routes::v1::dto::{ + PluginDto, PluginHealthResponse, PluginStatusResponse, PluginTestResult, PluginsListResponse, +}; +use codex::db::repositories::UserRepository; +use codex::utils::password; + +// ============================================================================= +// Helper functions +// ============================================================================= + +/// Create an admin user and return a JWT token +async fn create_admin_and_token( + db: &sea_orm::DatabaseConnection, + state: &codex::api::extractors::AppState, +) -> String { + let password_hash = password::hash_password("admin123").unwrap(); + let user = create_test_user("admin", "admin@example.com", &password_hash, true); + let created = UserRepository::create(db, &user).await.unwrap(); + generate_test_token(state, &created) +} + +/// Create a regular user and return a JWT token +async fn create_user_and_token( + db: &sea_orm::DatabaseConnection, + state: &codex::api::extractors::AppState, +) -> String { + let password_hash = password::hash_password("user123").unwrap(); + let user = create_test_user("regularuser", "user@example.com", &password_hash, false); + let created = UserRepository::create(db, &user).await.unwrap(); + generate_test_token(state, &created) +} + +// ============================================================================= +// Authorization Tests +// ============================================================================= + +#[tokio::test] +async fn test_list_plugins_requires_admin() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let app = create_test_router(state.clone()).await; + let token = create_user_and_token(&db, &state).await; + + let request = get_request_with_auth("/api/v1/admin/plugins", &token); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn test_list_plugins_requires_auth() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db).await; + let app = create_test_router(state).await; + + let request = common::http::get_request("/api/v1/admin/plugins"); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +// ============================================================================= +// List Plugins Tests +// ============================================================================= + +#[tokio::test] +async fn test_list_plugins_empty() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let app = create_test_router(state.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + let request = get_request_with_auth("/api/v1/admin/plugins", &token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let response = response.expect("Expected response body"); + assert_eq!(response.total, 0); + assert!(response.plugins.is_empty()); +} + +// ============================================================================= +// Create Plugin Tests +// ============================================================================= + +#[tokio::test] +async fn test_create_plugin_success() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let app = create_test_router(state.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + let body = json!({ + "name": "test-plugin", + "displayName": "Test Plugin", + "description": "A test plugin", + "command": "node", + "args": ["/path/to/plugin.js"], + "permissions": ["metadata:write:summary"], + "scopes": ["series:detail"], + "enabled": false + }); + + let request = post_json_request_with_auth("/api/v1/admin/plugins", &body, &token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::CREATED); + let response = response.expect("Expected response body"); + assert_eq!(response.plugin.name, "test-plugin"); + assert_eq!(response.plugin.display_name, "Test Plugin"); + assert_eq!(response.plugin.command, "node"); + assert!(!response.plugin.enabled); +} + +#[tokio::test] +async fn test_create_plugin_minimal() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let app = create_test_router(state.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + let body = json!({ + "name": "minimal-plugin", + "displayName": "Minimal", + "command": "node" // Must be in allowed commands list + }); + + let request = post_json_request_with_auth("/api/v1/admin/plugins", &body, &token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::CREATED); + let response = response.expect("Expected response body"); + assert_eq!(response.plugin.name, "minimal-plugin"); + assert_eq!(response.plugin.plugin_type, "system"); + assert_eq!(response.plugin.credential_delivery, "env"); +} + +#[tokio::test] +async fn test_create_plugin_invalid_name() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let app = create_test_router(state.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + let body = json!({ + "name": "Invalid-Name", // Contains uppercase and dash + "displayName": "Test", + "command": "node" + }); + + let request = post_json_request_with_auth("/api/v1/admin/plugins", &body, &token); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_create_plugin_invalid_permission() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let app = create_test_router(state.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + let body = json!({ + "name": "test-plugin", + "displayName": "Test", + "command": "node", + "permissions": ["invalid:permission"] + }); + + let request = post_json_request_with_auth("/api/v1/admin/plugins", &body, &token); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_create_plugin_invalid_scope() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let app = create_test_router(state.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + let body = json!({ + "name": "test-plugin", + "displayName": "Test", + "command": "node", + "scopes": ["invalid:scope"] + }); + + let request = post_json_request_with_auth("/api/v1/admin/plugins", &body, &token); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_create_plugin_duplicate_name() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + // Create first plugin + let app = create_test_router(state.clone()).await; + let body = json!({ + "name": "duplicate-test", + "displayName": "First", + "command": "node" + }); + let request = post_json_request_with_auth("/api/v1/admin/plugins", &body, &token); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::CREATED); + + // Try to create second plugin with same name + let app = create_test_router(state.clone()).await; + let body = json!({ + "name": "duplicate-test", + "displayName": "Second", + "command": "python" + }); + let request = post_json_request_with_auth("/api/v1/admin/plugins", &body, &token); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::CONFLICT); +} + +// ============================================================================= +// Get Plugin Tests +// ============================================================================= + +#[tokio::test] +async fn test_get_plugin_success() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + // Create a plugin + let app = create_test_router(state.clone()).await; + let body = json!({ + "name": "get-test-plugin", + "displayName": "Get Test", + "command": "node" + }); + let request = post_json_request_with_auth("/api/v1/admin/plugins", &body, &token); + let (_, created): (StatusCode, Option) = + make_json_request(app, request).await; + let created = created.unwrap().plugin; + + // Get the plugin + let app = create_test_router(state.clone()).await; + let request = get_request_with_auth(&format!("/api/v1/admin/plugins/{}", created.id), &token); + let (status, response): (StatusCode, Option) = make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let response = response.expect("Expected response body"); + assert_eq!(response.id, created.id); + assert_eq!(response.name, "get-test-plugin"); +} + +#[tokio::test] +async fn test_get_plugin_not_found() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let app = create_test_router(state.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + let fake_id = uuid::Uuid::new_v4(); + let request = get_request_with_auth(&format!("/api/v1/admin/plugins/{}", fake_id), &token); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +// ============================================================================= +// Update Plugin Tests +// ============================================================================= + +#[tokio::test] +async fn test_update_plugin_success() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + // Create a plugin + let app = create_test_router(state.clone()).await; + let body = json!({ + "name": "update-test-plugin", + "displayName": "Original Name", + "command": "node" + }); + let request = post_json_request_with_auth("/api/v1/admin/plugins", &body, &token); + let (_, created): (StatusCode, Option) = + make_json_request(app, request).await; + let created = created.unwrap().plugin; + + // Update the plugin + let app = create_test_router(state.clone()).await; + let update_body = json!({ + "displayName": "Updated Name" + }); + let request = patch_json_request_with_auth( + &format!("/api/v1/admin/plugins/{}", created.id), + &update_body, + &token, + ); + let (status, response): (StatusCode, Option) = make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let response = response.expect("Expected response body"); + assert_eq!(response.display_name, "Updated Name"); + assert_eq!(response.name, "update-test-plugin"); // Name shouldn't change +} + +#[tokio::test] +async fn test_update_plugin_permissions() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + // Create a plugin with initial permissions + let app = create_test_router(state.clone()).await; + let body = json!({ + "name": "perm-test-plugin", + "displayName": "Perm Test", + "command": "node", + "permissions": ["metadata:read"] + }); + let request = post_json_request_with_auth("/api/v1/admin/plugins", &body, &token); + let (_, created): (StatusCode, Option) = + make_json_request(app, request).await; + let created = created.unwrap().plugin; + + // Update permissions + let app = create_test_router(state.clone()).await; + let update_body = json!({ + "permissions": ["metadata:write:summary", "metadata:write:genres"] + }); + let request = patch_json_request_with_auth( + &format!("/api/v1/admin/plugins/{}", created.id), + &update_body, + &token, + ); + let (status, response): (StatusCode, Option) = make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let response = response.expect("Expected response body"); + assert_eq!(response.permissions.len(), 2); + assert!(response + .permissions + .contains(&"metadata:write:summary".to_string())); + assert!(response + .permissions + .contains(&"metadata:write:genres".to_string())); +} + +// ============================================================================= +// Delete Plugin Tests +// ============================================================================= + +#[tokio::test] +async fn test_delete_plugin_success() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + // Create a plugin + let app = create_test_router(state.clone()).await; + let body = json!({ + "name": "delete-test-plugin", + "displayName": "Delete Test", + "command": "node" + }); + let request = post_json_request_with_auth("/api/v1/admin/plugins", &body, &token); + let (_, created): (StatusCode, Option) = + make_json_request(app, request).await; + let created = created.unwrap().plugin; + + // Delete the plugin + let app = create_test_router(state.clone()).await; + let request = + delete_request_with_auth(&format!("/api/v1/admin/plugins/{}", created.id), &token); + let (status, _): (StatusCode, Option<()>) = make_json_request(app, request).await; + + assert_eq!(status, StatusCode::NO_CONTENT); + + // Verify it's gone + let app = create_test_router(state.clone()).await; + let request = get_request_with_auth(&format!("/api/v1/admin/plugins/{}", created.id), &token); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_delete_plugin_not_found() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let app = create_test_router(state.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + let fake_id = uuid::Uuid::new_v4(); + let request = delete_request_with_auth(&format!("/api/v1/admin/plugins/{}", fake_id), &token); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +// ============================================================================= +// Enable/Disable Plugin Tests +// ============================================================================= + +#[tokio::test] +async fn test_enable_plugin() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + // Create a disabled plugin + let app = create_test_router(state.clone()).await; + let body = json!({ + "name": "enable-test-plugin", + "displayName": "Enable Test", + "command": "node", + "enabled": false + }); + let request = post_json_request_with_auth("/api/v1/admin/plugins", &body, &token); + let (_, created): (StatusCode, Option) = + make_json_request(app, request).await; + let created = created.unwrap().plugin; + assert!(!created.enabled); + + // Enable the plugin + let app = create_test_router(state.clone()).await; + let request = post_request_with_auth( + &format!("/api/v1/admin/plugins/{}/enable", created.id), + &token, + ); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let response = response.expect("Expected response body"); + assert!(response.plugin.enabled); + assert!(response.message.contains("enabled")); +} + +#[tokio::test] +async fn test_disable_plugin() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + // Create an enabled plugin + let app = create_test_router(state.clone()).await; + let body = json!({ + "name": "disable-test-plugin", + "displayName": "Disable Test", + "command": "node", + "enabled": true + }); + let request = post_json_request_with_auth("/api/v1/admin/plugins", &body, &token); + let (_, created): (StatusCode, Option) = + make_json_request(app, request).await; + let created = created.unwrap().plugin; + assert!(created.enabled); + + // Disable the plugin + let app = create_test_router(state.clone()).await; + let request = post_request_with_auth( + &format!("/api/v1/admin/plugins/{}/disable", created.id), + &token, + ); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let response = response.expect("Expected response body"); + assert!(!response.plugin.enabled); + assert!(response.message.contains("disabled")); +} + +// ============================================================================= +// Plugin Health Tests +// ============================================================================= + +#[tokio::test] +async fn test_get_plugin_health() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + // Create a plugin + let app = create_test_router(state.clone()).await; + let body = json!({ + "name": "health-test-plugin", + "displayName": "Health Test", + "command": "node" + }); + let request = post_json_request_with_auth("/api/v1/admin/plugins", &body, &token); + let (_, created): (StatusCode, Option) = + make_json_request(app, request).await; + let created = created.unwrap().plugin; + + // Get health + let app = create_test_router(state.clone()).await; + let request = get_request_with_auth( + &format!("/api/v1/admin/plugins/{}/health", created.id), + &token, + ); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let response = response.expect("Expected response body"); + assert_eq!(response.health.plugin_id, created.id); + assert_eq!(response.health.name, "health-test-plugin"); + assert_eq!(response.health.failure_count, 0); +} + +// ============================================================================= +// Reset Failure Count Tests +// ============================================================================= + +#[tokio::test] +async fn test_reset_plugin_failures() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + // Create a plugin + let app = create_test_router(state.clone()).await; + let body = json!({ + "name": "reset-test-plugin", + "displayName": "Reset Test", + "command": "node" + }); + let request = post_json_request_with_auth("/api/v1/admin/plugins", &body, &token); + let (_, created): (StatusCode, Option) = + make_json_request(app, request).await; + let created = created.unwrap().plugin; + + // Reset failures (even though there aren't any, the endpoint should work) + let app = create_test_router(state.clone()).await; + let request = post_request_with_auth( + &format!("/api/v1/admin/plugins/{}/reset", created.id), + &token, + ); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let response = response.expect("Expected response body"); + assert_eq!(response.plugin.failure_count, 0); + assert!(response.message.contains("reset")); +} + +// ============================================================================= +// Test Plugin Connection Tests +// ============================================================================= + +#[tokio::test] +async fn test_test_plugin_not_found() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let app = create_test_router(state.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + let fake_id = uuid::Uuid::new_v4(); + let request = + post_request_with_auth(&format!("/api/v1/admin/plugins/{}/test", fake_id), &token); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_test_plugin_invalid_command() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + // Create a plugin with a valid command but nonexistent script + // This tests runtime failure rather than validation failure + let app = create_test_router(state.clone()).await; + let body = json!({ + "name": "invalid-cmd-plugin", + "displayName": "Invalid Command", + "command": "node", // Valid command + "args": ["/nonexistent/script/that/does/not/exist.js"] // Nonexistent script + }); + let request = post_json_request_with_auth("/api/v1/admin/plugins", &body, &token); + let (status, created): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::CREATED); + let created = created.unwrap().plugin; + + // Test the plugin - should fail gracefully because the script doesn't exist + let app = create_test_router(state.clone()).await; + let request = post_request_with_auth( + &format!("/api/v1/admin/plugins/{}/test", created.id), + &token, + ); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let response = response.expect("Expected response body"); + assert!(!response.success); + // The error could be about module not found, file not found, or spawn failure + assert!( + response.message.to_lowercase().contains("fail") + || response.message.to_lowercase().contains("error") + || response.message.to_lowercase().contains("not found") + || response.message.contains("MODULE_NOT_FOUND") + || response.message.contains("ENOENT"), + "Expected error message but got: {}", + response.message + ); +} + +// ============================================================================= +// List with Plugins Tests +// ============================================================================= + +#[tokio::test] +async fn test_list_plugins_with_data() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + // Create multiple plugins + for i in 1..=3 { + let app = create_test_router(state.clone()).await; + let body = json!({ + "name": format!("list-test-plugin-{}", i), + "displayName": format!("List Test {}", i), + "command": "node" + }); + let request = post_json_request_with_auth("/api/v1/admin/plugins", &body, &token); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::CREATED); + } + + // List all plugins + let app = create_test_router(state.clone()).await; + let request = get_request_with_auth("/api/v1/admin/plugins", &token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let response = response.expect("Expected response body"); + assert_eq!(response.total, 3); + assert_eq!(response.plugins.len(), 3); +} + +// ============================================================================= +// Plugin Actions API Tests (Phase 4) +// ============================================================================= + +use codex::api::routes::v1::dto::{ExecutePluginResponse, PluginActionsResponse}; + +#[tokio::test] +async fn test_get_plugin_actions_requires_auth() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db).await; + let app = create_test_router(state).await; + + let request = common::http::get_request("/api/v1/plugins/actions?scope=series:detail"); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_get_plugin_actions_invalid_scope() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let app = create_test_router(state.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + let request = get_request_with_auth("/api/v1/plugins/actions?scope=invalid:scope", &token); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_get_plugin_actions_empty() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let app = create_test_router(state.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + let request = get_request_with_auth("/api/v1/plugins/actions?scope=series:detail", &token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let response = response.expect("Expected response body"); + assert!(response.actions.is_empty()); + assert_eq!(response.scope, "series:detail"); +} + +#[tokio::test] +async fn test_execute_plugin_not_found() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let app = create_test_router(state.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + let fake_id = uuid::Uuid::new_v4(); + let body = json!({ + "action": "ping" + }); + let request = post_json_request_with_auth( + &format!("/api/v1/plugins/{}/execute", fake_id), + &body, + &token, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_execute_plugin_invalid_method() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + // Create a plugin first + let app = create_test_router(state.clone()).await; + let body = json!({ + "name": "exec-test-plugin", + "displayName": "Exec Test", + "command": "node" + }); + let request = post_json_request_with_auth("/api/v1/admin/plugins", &body, &token); + let (_, created): (StatusCode, Option) = + make_json_request(app, request).await; + let created = created.unwrap().plugin; + + // Try to execute with invalid action + let app = create_test_router(state.clone()).await; + let body = json!({ + "action": "invalid_action" + }); + let request = post_json_request_with_auth( + &format!("/api/v1/plugins/{}/execute", created.id), + &body, + &token, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + + // Invalid action should result in 422 (unprocessable entity) due to deserialization failure + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn test_execute_plugin_disabled() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + // Create a disabled plugin + let app = create_test_router(state.clone()).await; + let body = json!({ + "name": "disabled-plugin", + "displayName": "Disabled Plugin", + "command": "node", + "enabled": false + }); + let request = post_json_request_with_auth("/api/v1/admin/plugins", &body, &token); + let (_, created): (StatusCode, Option) = + make_json_request(app, request).await; + let created = created.unwrap().plugin; + + // Try to execute + let app = create_test_router(state.clone()).await; + let body = json!({ + "action": "ping" + }); + let request = post_json_request_with_auth( + &format!("/api/v1/plugins/{}/execute", created.id), + &body, + &token, + ); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let response = response.expect("Expected response body"); + assert!(!response.success); + assert!(response.error.as_ref().unwrap().contains("disabled")); +} + +#[tokio::test] +async fn test_preview_series_metadata_series_not_found() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let app = create_test_router(state.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + let fake_series_id = uuid::Uuid::new_v4(); + let fake_plugin_id = uuid::Uuid::new_v4(); + let body = json!({ + "pluginId": fake_plugin_id.to_string(), + "externalId": "12345" + }); + let request = post_json_request_with_auth( + &format!("/api/v1/series/{}/metadata/preview", fake_series_id), + &body, + &token, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_apply_series_metadata_series_not_found() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let app = create_test_router(state.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + let fake_series_id = uuid::Uuid::new_v4(); + let fake_plugin_id = uuid::Uuid::new_v4(); + let body = json!({ + "pluginId": fake_plugin_id.to_string(), + "externalId": "12345" + }); + let request = post_json_request_with_auth( + &format!("/api/v1/series/{}/metadata/apply", fake_series_id), + &body, + &token, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_preview_series_metadata_requires_auth() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db).await; + let app = create_test_router(state).await; + + let fake_series_id = uuid::Uuid::new_v4(); + let body = json!({ + "pluginId": uuid::Uuid::new_v4().to_string(), + "externalId": "12345" + }); + let request = common::http::post_json_request( + &format!("/api/v1/series/{}/metadata/preview", fake_series_id), + &body, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_apply_series_metadata_requires_auth() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db).await; + let app = create_test_router(state).await; + + let fake_series_id = uuid::Uuid::new_v4(); + let body = json!({ + "pluginId": uuid::Uuid::new_v4().to_string(), + "externalId": "12345" + }); + let request = common::http::post_json_request( + &format!("/api/v1/series/{}/metadata/apply", fake_series_id), + &body, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +// ============================================================================= +// Permission-Based Access Tests (Phase 8) +// ============================================================================= + +/// Create a maintainer user and return a JWT token. +/// Maintainers have SeriesWrite permission but not PluginsManage. +async fn create_maintainer_and_token( + db: &sea_orm::DatabaseConnection, + state: &codex::api::extractors::AppState, +) -> String { + use codex::api::permissions::UserRole; + use codex::db::entities::users; + use sea_orm::ActiveModelTrait; + + let password_hash = password::hash_password("maintainer123").unwrap(); + let maintainer = users::ActiveModel { + id: sea_orm::ActiveValue::Set(uuid::Uuid::new_v4()), + username: sea_orm::ActiveValue::Set("maintainer".to_string()), + email: sea_orm::ActiveValue::Set("maintainer@example.com".to_string()), + password_hash: sea_orm::ActiveValue::Set(password_hash), + role: sea_orm::ActiveValue::Set(UserRole::Maintainer.to_string()), + is_active: sea_orm::ActiveValue::Set(true), + email_verified: sea_orm::ActiveValue::Set(true), + permissions: sea_orm::ActiveValue::Set(serde_json::json!([])), + created_at: sea_orm::ActiveValue::Set(chrono::Utc::now()), + updated_at: sea_orm::ActiveValue::Set(chrono::Utc::now()), + last_login_at: sea_orm::ActiveValue::Set(None), + }; + let created = maintainer.insert(db).await.unwrap(); + generate_test_token(state, &created) +} + +#[tokio::test] +async fn test_plugin_crud_requires_plugins_manage_permission() { + // A maintainer (who has SeriesWrite but NOT PluginsManage) should NOT be able + // to create, update, or delete plugins + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let maintainer_token = create_maintainer_and_token(&db, &state).await; + + // Try to list plugins - should fail (requires PluginsManage) + let app = create_test_router(state.clone()).await; + let request = get_request_with_auth("/api/v1/admin/plugins", &maintainer_token); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "Maintainer should not list plugins" + ); + + // Try to create a plugin - should fail + let app = create_test_router(state.clone()).await; + let body = json!({ + "name": "test-plugin", + "displayName": "Test Plugin", + "command": "node" + }); + let request = post_json_request_with_auth("/api/v1/admin/plugins", &body, &maintainer_token); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "Maintainer should not create plugins" + ); +} + +#[tokio::test] +async fn test_reader_cannot_access_plugin_actions() { + // A reader (no SeriesWrite) should not see plugin actions + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let reader_token = create_user_and_token(&db, &state).await; + + // Get plugin actions - reader can view the actions endpoint (LibrariesRead) + // but won't see any plugins because they lack SeriesWrite + let app = create_test_router(state.clone()).await; + let request = + get_request_with_auth("/api/v1/plugins/actions?scope=series:detail", &reader_token); + let (status, response): ( + StatusCode, + Option, + ) = make_json_request(app, request).await; + + // Reader has LibrariesRead, so they can access the endpoint + assert_eq!(status, StatusCode::OK); + // But since they don't have SeriesWrite, the actions list should be empty + // (no plugins will pass the permission filter) + let response = response.expect("Expected response body"); + assert!( + response.actions.is_empty(), + "Reader should not see any plugin actions (no SeriesWrite permission)" + ); +} + +#[tokio::test] +async fn test_maintainer_can_use_plugin_actions() { + // A maintainer (has SeriesWrite) should be able to access plugin actions + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let maintainer_token = create_maintainer_and_token(&db, &state).await; + + // Get plugin actions - maintainer can view + let app = create_test_router(state.clone()).await; + let request = get_request_with_auth( + "/api/v1/plugins/actions?scope=series:detail", + &maintainer_token, + ); + let (status, response): ( + StatusCode, + Option, + ) = make_json_request(app, request).await; + + // Maintainer has LibrariesRead, so they can access the endpoint + assert_eq!(status, StatusCode::OK); + // The actions list will be empty because there are no plugins configured, + // but the endpoint is accessible (unlike for readers when plugins exist) + let response = response.expect("Expected response body"); + assert_eq!(response.scope, "series:detail"); +} diff --git a/tests/api/rate_limit.rs b/tests/api/rate_limit.rs index 08d9b133..72ab2ccc 100644 --- a/tests/api/rate_limit.rs +++ b/tests/api/rate_limit.rs @@ -64,8 +64,8 @@ async fn create_rate_limited_app_state( use codex::events::EventBroadcaster; use codex::services::email::EmailService; use codex::services::{ - AuthTrackingService, FileCleanupService, PdfPageCache, ReadProgressService, - SettingsService, ThumbnailService, + plugin::PluginManager, AuthTrackingService, FileCleanupService, PdfPageCache, + PluginMetricsService, ReadProgressService, SettingsService, ThumbnailService, }; use codex::utils::jwt::JwtService; @@ -93,6 +93,8 @@ async fn create_rate_limited_app_state( // Create rate limiter service with test config let rate_limiter_service = Some(Arc::new(RateLimiterService::new(Arc::new(config.clone())))); + let plugin_manager = Arc::new(PluginManager::with_defaults(Arc::new(db.clone()))); + let plugin_metrics_service = Arc::new(PluginMetricsService::new()); Arc::new(AppState { db, @@ -113,6 +115,8 @@ async fn create_rate_limited_app_state( inflight_thumbnails: Arc::new(InflightThumbnailTracker::new()), user_auth_cache: Arc::new(UserAuthCache::new()), rate_limiter_service, + plugin_manager, + plugin_metrics_service, }) } diff --git a/tests/api/task_metrics.rs b/tests/api/task_metrics.rs index 6f56439f..8d20e693 100644 --- a/tests/api/task_metrics.rs +++ b/tests/api/task_metrics.rs @@ -14,8 +14,9 @@ use codex::db::repositories::UserRepository; use codex::events::EventBroadcaster; use codex::services::email::EmailService; use codex::services::{ - AuthTrackingService, FileCleanupService, InflightThumbnailTracker, PdfPageCache, - ReadProgressService, SettingsService, TaskMetricsService, ThumbnailService, + plugin::PluginManager, AuthTrackingService, FileCleanupService, InflightThumbnailTracker, + PdfPageCache, PluginMetricsService, ReadProgressService, SettingsService, TaskMetricsService, + ThumbnailService, }; use codex::utils::jwt::JwtService; use codex::utils::password; @@ -58,6 +59,8 @@ async fn create_test_app_state_with_metrics(db: DatabaseConnection) -> Arc Arc) = make_json_request(app, request).await; @@ -114,7 +115,7 @@ async fn test_generate_thumbnails_all_success() { } #[tokio::test] -async fn test_generate_thumbnails_with_force() { +async fn test_generate_book_thumbnails_with_force() { let (db, temp_dir) = setup_test_db().await; create_test_cbz_files_in_dir(temp_dir.path()); @@ -143,7 +144,8 @@ async fn test_generate_thumbnails_with_force() { // Trigger with force=true let request_body = json!({ "force": true }); - let request = post_json_request_with_auth("/api/v1/thumbnails/generate", &request_body, &token); + let request = + post_json_request_with_auth("/api/v1/books/thumbnails/generate", &request_body, &token); let (status, response): (StatusCode, Option) = make_json_request(app, request).await; @@ -153,7 +155,7 @@ async fn test_generate_thumbnails_with_force() { } #[tokio::test] -async fn test_generate_thumbnails_requires_write_permission() { +async fn test_generate_book_thumbnails_requires_write_permission() { let (db, _temp_dir) = setup_test_db().await; let state = create_test_app_state(db.clone()).await; @@ -161,7 +163,8 @@ async fn test_generate_thumbnails_requires_write_permission() { let app = create_test_router_with_app_state(state); let request_body = json!({}); - let request = post_json_request_with_auth("/api/v1/thumbnails/generate", &request_body, &token); + let request = + post_json_request_with_auth("/api/v1/books/thumbnails/generate", &request_body, &token); let (status, _): (StatusCode, Option) = make_json_request(app, request).await; @@ -169,11 +172,11 @@ async fn test_generate_thumbnails_requires_write_permission() { } // ============================================================================ -// POST /api/v1/libraries/:library_id/thumbnails/generate Tests +// POST /api/v1/libraries/:library_id/books/thumbnails/generate Tests // ============================================================================ #[tokio::test] -async fn test_generate_library_thumbnails_success() { +async fn test_generate_library_book_thumbnails_success() { let (db, temp_dir) = setup_test_db().await; create_test_cbz_files_in_dir(temp_dir.path()); @@ -201,8 +204,8 @@ async fn test_generate_library_thumbnails_success() { let app = create_test_router_with_app_state(state); - // Trigger thumbnail generation for library - let uri = format!("/api/v1/libraries/{}/thumbnails/generate", library.id); + // Trigger thumbnail generation for library books + let uri = format!("/api/v1/libraries/{}/books/thumbnails/generate", library.id); let request_body = json!({}); let request = post_json_request_with_auth(&uri, &request_body, &token); @@ -215,7 +218,7 @@ async fn test_generate_library_thumbnails_success() { } #[tokio::test] -async fn test_generate_library_thumbnails_with_force() { +async fn test_generate_library_book_thumbnails_with_force() { let (db, temp_dir) = setup_test_db().await; create_test_cbz_files_in_dir(temp_dir.path()); @@ -242,7 +245,7 @@ async fn test_generate_library_thumbnails_with_force() { let app = create_test_router_with_app_state(state); // Trigger with force=true - let uri = format!("/api/v1/libraries/{}/thumbnails/generate", library.id); + let uri = format!("/api/v1/libraries/{}/books/thumbnails/generate", library.id); let request_body = json!({ "force": true }); let request = post_json_request_with_auth(&uri, &request_body, &token); @@ -254,7 +257,7 @@ async fn test_generate_library_thumbnails_with_force() { } #[tokio::test] -async fn test_generate_library_thumbnails_not_found() { +async fn test_generate_library_book_thumbnails_not_found() { let (db, _temp_dir) = setup_test_db().await; let state = create_test_app_state(db.clone()).await; @@ -262,7 +265,7 @@ async fn test_generate_library_thumbnails_not_found() { let app = create_test_router_with_app_state(state); let fake_id = uuid::Uuid::new_v4(); - let uri = format!("/api/v1/libraries/{}/thumbnails/generate", fake_id); + let uri = format!("/api/v1/libraries/{}/books/thumbnails/generate", fake_id); let request_body = json!({}); let request = post_json_request_with_auth(&uri, &request_body, &token); @@ -272,7 +275,7 @@ async fn test_generate_library_thumbnails_not_found() { } #[tokio::test] -async fn test_generate_library_thumbnails_requires_write_permission() { +async fn test_generate_library_book_thumbnails_requires_write_permission() { let (db, temp_dir) = setup_test_db().await; let library = LibraryRepository::create( @@ -288,7 +291,7 @@ async fn test_generate_library_thumbnails_requires_write_permission() { let token = create_readonly_and_token(&db, &state).await; let app = create_test_router_with_app_state(state); - let uri = format!("/api/v1/libraries/{}/thumbnails/generate", library.id); + let uri = format!("/api/v1/libraries/{}/books/thumbnails/generate", library.id); let request_body = json!({}); let request = post_json_request_with_auth(&uri, &request_body, &token); @@ -298,11 +301,114 @@ async fn test_generate_library_thumbnails_requires_write_permission() { } // ============================================================================ -// POST /api/v1/series/:series_id/thumbnails/generate Tests +// POST /api/v1/series/thumbnails/generate Tests (Batch Series Thumbnails) // ============================================================================ #[tokio::test] -async fn test_generate_series_thumbnails_success() { +async fn test_generate_series_thumbnails_batch_success() { + let (db, temp_dir) = setup_test_db().await; + + create_test_cbz_files_in_dir(temp_dir.path()); + + let library = LibraryRepository::create( + &db, + "Test Library", + temp_dir.path().to_str().unwrap(), + ScanningStrategy::Default, + ) + .await + .unwrap(); + + let state = create_test_app_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + // Scan to detect files and create series + trigger_scan_task(&state.db, library.id, ScanMode::Normal) + .await + .unwrap(); + + let worker = TaskWorker::new(db.clone()).with_poll_interval(Duration::from_millis(100)); + worker.process_once().await.ok(); + tokio::time::sleep(Duration::from_millis(100)).await; + + let app = create_test_router_with_app_state(state); + + // Trigger batch series thumbnail generation + let request_body = json!({}); + let request = + post_json_request_with_auth("/api/v1/series/thumbnails/generate", &request_body, &token); + + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let task_response = response.unwrap(); + assert!(!task_response.task_id.to_string().is_empty()); +} + +#[tokio::test] +async fn test_generate_series_thumbnails_batch_with_library_id() { + let (db, temp_dir) = setup_test_db().await; + + create_test_cbz_files_in_dir(temp_dir.path()); + + let library = LibraryRepository::create( + &db, + "Test Library", + temp_dir.path().to_str().unwrap(), + ScanningStrategy::Default, + ) + .await + .unwrap(); + + let state = create_test_app_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + trigger_scan_task(&state.db, library.id, ScanMode::Normal) + .await + .unwrap(); + + let worker = TaskWorker::new(db.clone()).with_poll_interval(Duration::from_millis(100)); + worker.process_once().await.ok(); + tokio::time::sleep(Duration::from_millis(100)).await; + + let app = create_test_router_with_app_state(state); + + // Trigger with library_id scope + let request_body = json!({ "library_id": library.id.to_string() }); + let request = + post_json_request_with_auth("/api/v1/series/thumbnails/generate", &request_body, &token); + + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + assert!(response.is_some()); +} + +#[tokio::test] +async fn test_generate_series_thumbnails_batch_requires_write_permission() { + let (db, _temp_dir) = setup_test_db().await; + + let state = create_test_app_state(db.clone()).await; + let token = create_readonly_and_token(&db, &state).await; + let app = create_test_router_with_app_state(state); + + let request_body = json!({}); + let request = + post_json_request_with_auth("/api/v1/series/thumbnails/generate", &request_body, &token); + + let (status, _): (StatusCode, Option) = make_json_request(app, request).await; + + assert_eq!(status, StatusCode::FORBIDDEN); +} + +// ============================================================================ +// POST /api/v1/series/:series_id/thumbnail/generate Tests (Single Series) +// ============================================================================ + +#[tokio::test] +async fn test_generate_series_thumbnail_single_success() { let (db, temp_dir) = setup_test_db().await; create_test_cbz_files_in_dir(temp_dir.path()); @@ -341,8 +447,8 @@ async fn test_generate_series_thumbnails_success() { let app = create_test_router_with_app_state(state); - // Trigger thumbnail generation for series - let uri = format!("/api/v1/series/{}/thumbnails/generate", series.id); + // Trigger thumbnail generation for single series (singular - uses GenerateSeriesThumbnail) + let uri = format!("/api/v1/series/{}/thumbnail/generate", series.id); let request_body = json!({}); let request = post_json_request_with_auth(&uri, &request_body, &token); @@ -355,7 +461,7 @@ async fn test_generate_series_thumbnails_success() { } #[tokio::test] -async fn test_generate_series_thumbnails_with_force() { +async fn test_generate_series_thumbnail_single_with_force() { let (db, temp_dir) = setup_test_db().await; create_test_cbz_files_in_dir(temp_dir.path()); @@ -393,7 +499,7 @@ async fn test_generate_series_thumbnails_with_force() { let app = create_test_router_with_app_state(state); // Trigger with force=true - let uri = format!("/api/v1/series/{}/thumbnails/generate", series.id); + let uri = format!("/api/v1/series/{}/thumbnail/generate", series.id); let request_body = json!({ "force": true }); let request = post_json_request_with_auth(&uri, &request_body, &token); @@ -405,7 +511,7 @@ async fn test_generate_series_thumbnails_with_force() { } #[tokio::test] -async fn test_generate_series_thumbnails_not_found() { +async fn test_generate_series_thumbnail_single_not_found() { let (db, _temp_dir) = setup_test_db().await; let state = create_test_app_state(db.clone()).await; @@ -413,7 +519,7 @@ async fn test_generate_series_thumbnails_not_found() { let app = create_test_router_with_app_state(state); let fake_id = uuid::Uuid::new_v4(); - let uri = format!("/api/v1/series/{}/thumbnails/generate", fake_id); + let uri = format!("/api/v1/series/{}/thumbnail/generate", fake_id); let request_body = json!({}); let request = post_json_request_with_auth(&uri, &request_body, &token); @@ -423,7 +529,7 @@ async fn test_generate_series_thumbnails_not_found() { } #[tokio::test] -async fn test_generate_series_thumbnails_requires_write_permission() { +async fn test_generate_series_thumbnail_single_requires_write_permission() { let (db, temp_dir) = setup_test_db().await; let library = LibraryRepository::create( @@ -443,7 +549,145 @@ async fn test_generate_series_thumbnails_requires_write_permission() { let token = create_readonly_and_token(&db, &state).await; let app = create_test_router_with_app_state(state); - let uri = format!("/api/v1/series/{}/thumbnails/generate", series.id); + let uri = format!("/api/v1/series/{}/thumbnail/generate", series.id); + let request_body = json!({}); + let request = post_json_request_with_auth(&uri, &request_body, &token); + + let (status, _): (StatusCode, Option) = make_json_request(app, request).await; + + assert_eq!(status, StatusCode::FORBIDDEN); +} + +// ============================================================================ +// POST /api/v1/libraries/:library_id/series/thumbnails/generate Tests +// ============================================================================ + +#[tokio::test] +async fn test_generate_library_series_thumbnails_success() { + let (db, temp_dir) = setup_test_db().await; + + create_test_cbz_files_in_dir(temp_dir.path()); + + let library = LibraryRepository::create( + &db, + "Test Library", + temp_dir.path().to_str().unwrap(), + ScanningStrategy::Default, + ) + .await + .unwrap(); + + let state = create_test_app_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + // Scan to detect files and create series + trigger_scan_task(&state.db, library.id, ScanMode::Normal) + .await + .unwrap(); + + let worker = TaskWorker::new(db.clone()).with_poll_interval(Duration::from_millis(100)); + worker.process_once().await.ok(); + tokio::time::sleep(Duration::from_millis(100)).await; + + let app = create_test_router_with_app_state(state); + + // Trigger thumbnail generation for library series + let uri = format!( + "/api/v1/libraries/{}/series/thumbnails/generate", + library.id + ); + let request_body = json!({}); + let request = post_json_request_with_auth(&uri, &request_body, &token); + + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let task_response = response.unwrap(); + assert!(!task_response.task_id.to_string().is_empty()); +} + +#[tokio::test] +async fn test_generate_library_series_thumbnails_with_force() { + let (db, temp_dir) = setup_test_db().await; + + create_test_cbz_files_in_dir(temp_dir.path()); + + let library = LibraryRepository::create( + &db, + "Test Library", + temp_dir.path().to_str().unwrap(), + ScanningStrategy::Default, + ) + .await + .unwrap(); + + let state = create_test_app_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + trigger_scan_task(&state.db, library.id, ScanMode::Normal) + .await + .unwrap(); + + let worker = TaskWorker::new(db.clone()).with_poll_interval(Duration::from_millis(100)); + worker.process_once().await.ok(); + + let app = create_test_router_with_app_state(state); + + // Trigger with force=true + let uri = format!( + "/api/v1/libraries/{}/series/thumbnails/generate", + library.id + ); + let request_body = json!({ "force": true }); + let request = post_json_request_with_auth(&uri, &request_body, &token); + + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + assert!(response.is_some()); +} + +#[tokio::test] +async fn test_generate_library_series_thumbnails_not_found() { + let (db, _temp_dir) = setup_test_db().await; + + let state = create_test_app_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + let app = create_test_router_with_app_state(state); + + let fake_id = uuid::Uuid::new_v4(); + let uri = format!("/api/v1/libraries/{}/series/thumbnails/generate", fake_id); + let request_body = json!({}); + let request = post_json_request_with_auth(&uri, &request_body, &token); + + let (status, _): (StatusCode, Option) = make_json_request(app, request).await; + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_generate_library_series_thumbnails_requires_write_permission() { + let (db, temp_dir) = setup_test_db().await; + + let library = LibraryRepository::create( + &db, + "Test Library", + temp_dir.path().to_str().unwrap(), + ScanningStrategy::Default, + ) + .await + .unwrap(); + + let state = create_test_app_state(db.clone()).await; + let token = create_readonly_and_token(&db, &state).await; + let app = create_test_router_with_app_state(state); + + let uri = format!( + "/api/v1/libraries/{}/series/thumbnails/generate", + library.id + ); let request_body = json!({}); let request = post_json_request_with_auth(&uri, &request_body, &token); @@ -694,7 +938,7 @@ async fn test_generate_thumbnails_fan_out() { let app = create_test_router_with_app_state(state); // Trigger thumbnail generation for library (should create GenerateThumbnails task) - let uri = format!("/api/v1/libraries/{}/thumbnails/generate", library.id); + let uri = format!("/api/v1/libraries/{}/books/thumbnails/generate", library.id); let request_body = json!({ "force": true }); let request = post_json_request_with_auth(&uri, &request_body, &token); diff --git a/tests/common/http.rs b/tests/common/http.rs index 8f60c636..7e78603c 100644 --- a/tests/common/http.rs +++ b/tests/common/http.rs @@ -7,8 +7,8 @@ use codex::db::entities::users; use codex::events::EventBroadcaster; use codex::services::email::EmailService; use codex::services::{ - AuthTrackingService, FileCleanupService, InflightThumbnailTracker, PdfPageCache, - ReadProgressService, SettingsService, ThumbnailService, + plugin::PluginManager, AuthTrackingService, FileCleanupService, InflightThumbnailTracker, + PdfPageCache, PluginMetricsService, ReadProgressService, SettingsService, ThumbnailService, }; use codex::utils::jwt::JwtService; use http_body_util::BodyExt; @@ -42,6 +42,9 @@ pub async fn create_test_auth_state(db: DatabaseConnection) -> Arc { let auth_tracking_service = Arc::new(AuthTrackingService::new(db.clone())); let pdf_page_cache = Arc::new(PdfPageCache::new(&pdf_config.cache_dir, false)); // Disabled in tests + let plugin_manager = Arc::new(PluginManager::with_defaults(Arc::new(db.clone()))); + let plugin_metrics_service = Arc::new(PluginMetricsService::new()); + Arc::new(AppState { db, jwt_service, @@ -61,6 +64,8 @@ pub async fn create_test_auth_state(db: DatabaseConnection) -> Arc { inflight_thumbnails: Arc::new(InflightThumbnailTracker::new()), user_auth_cache: Arc::new(UserAuthCache::new()), rate_limiter_service: None, // Tests disable rate limiting by default + plugin_manager, + plugin_metrics_service, }) } @@ -87,6 +92,8 @@ pub async fn create_test_app_state(db: DatabaseConnection) -> Arc { let read_progress_service = Arc::new(ReadProgressService::new(db.clone())); let auth_tracking_service = Arc::new(AuthTrackingService::new(db.clone())); let pdf_page_cache = Arc::new(PdfPageCache::new(&pdf_config.cache_dir, false)); // Disabled in tests + let plugin_manager = Arc::new(PluginManager::with_defaults(Arc::new(db.clone()))); + let plugin_metrics_service = Arc::new(PluginMetricsService::new()); Arc::new(AppState { db, @@ -107,6 +114,8 @@ pub async fn create_test_app_state(db: DatabaseConnection) -> Arc { inflight_thumbnails: Arc::new(InflightThumbnailTracker::new()), user_auth_cache: Arc::new(UserAuthCache::new()), rate_limiter_service: None, // Tests disable rate limiting by default + plugin_manager, + plugin_metrics_service, }) } @@ -159,6 +168,8 @@ pub async fn create_test_router(state: Arc) -> Router { let read_progress_service = Arc::new(ReadProgressService::new(state.db.clone())); let auth_tracking_service = Arc::new(AuthTrackingService::new(state.db.clone())); let pdf_page_cache = Arc::new(PdfPageCache::new(&pdf_config.cache_dir, false)); // Disabled in tests + let plugin_manager = Arc::new(PluginManager::with_defaults(Arc::new(state.db.clone()))); + let plugin_metrics_service = Arc::new(PluginMetricsService::new()); let app_state = Arc::new(AppState { db: state.db.clone(), jwt_service: state.jwt_service.clone(), @@ -178,6 +189,8 @@ pub async fn create_test_router(state: Arc) -> Router { inflight_thumbnails: Arc::new(InflightThumbnailTracker::new()), user_auth_cache: Arc::new(UserAuthCache::new()), rate_limiter_service: None, // Tests disable rate limiting by default + plugin_manager, + plugin_metrics_service, }); let config = create_test_config(); create_router(app_state, &config) diff --git a/tests/db/auth.rs b/tests/db/auth.rs index 44cb64f1..6a16ade9 100644 --- a/tests/db/auth.rs +++ b/tests/db/auth.rs @@ -164,7 +164,7 @@ async fn test_permission_sets() { assert!(ADMIN_PERMISSIONS.contains(&Permission::SystemAdmin)); assert!(ADMIN_PERMISSIONS.contains(&Permission::UsersWrite)); assert!(ADMIN_PERMISSIONS.contains(&Permission::LibrariesDelete)); - assert_eq!(ADMIN_PERMISSIONS.len(), 20); + assert_eq!(ADMIN_PERMISSIONS.len(), 21); // Test permission serialization roundtrip let perms = READONLY_PERMISSIONS.clone(); @@ -219,7 +219,7 @@ async fn test_user_with_multiple_api_keys() { if key.name == "Mobile App" { assert_eq!(perms.len(), 5); // READONLY } else if key.name == "Admin Tool" { - assert_eq!(perms.len(), 20); // ADMIN + assert_eq!(perms.len(), 21); // ADMIN } else if key.name == "CI/CD" { assert_eq!(perms.len(), 1); assert!(perms.contains(&Permission::BooksRead)); diff --git a/tests/scheduler.rs b/tests/scheduler.rs index fdfe06f7..fac8d217 100644 --- a/tests/scheduler.rs +++ b/tests/scheduler.rs @@ -222,3 +222,184 @@ async fn test_scheduler_reload_schedules() { scheduler.shutdown().await.expect("Failed to shutdown"); } + +// ============================================================================= +// Thumbnail Cron Job Tests +// ============================================================================= + +/// Test that scheduler loads with empty book thumbnail cron schedule (disabled) +#[tokio::test] +async fn test_scheduler_with_empty_book_thumbnail_cron() { + let (db, _temp_dir) = setup_test_db().await; + + let mut scheduler = Scheduler::new(db.clone()) + .await + .expect("Failed to create scheduler"); + + // Should start successfully without loading book thumbnail job (empty by default) + scheduler.start().await.expect("Failed to start scheduler"); + + // Verify no GenerateThumbnails tasks were enqueued + let tasks = TaskRepository::list( + &db, + Some("pending".to_string()), + Some("generate_thumbnails".to_string()), + Some(100), + ) + .await + .expect("Failed to list tasks"); + + assert_eq!( + tasks.len(), + 0, + "No thumbnail generation tasks should be enqueued with empty cron" + ); + + scheduler.shutdown().await.expect("Failed to shutdown"); +} + +/// Test that scheduler loads book thumbnail cron when enabled with a valid schedule +#[tokio::test] +async fn test_scheduler_with_book_thumbnail_cron_enabled() { + let (db, _temp_dir) = setup_test_db().await; + + // Create a test user for settings updates + let password_hash = password::hash_password("test123").unwrap(); + let user = create_test_user("test", "test@example.com", &password_hash, true); + let created_user = UserRepository::create(&db, &user).await.unwrap(); + + // Set book thumbnail cron schedule + SettingsRepository::set( + &db, + "thumbnail.book_cron_schedule", + "0 0 3 * * *".to_string(), // Every day at 3:00:00 AM + created_user.id, + None, + None, + ) + .await + .expect("Failed to update setting"); + + let mut scheduler = Scheduler::new(db.clone()) + .await + .expect("Failed to create scheduler"); + + // Should start successfully and load the book thumbnail job + scheduler.start().await.expect("Failed to start scheduler"); + + // Note: We can't easily verify the cron job was added without triggering it + // This test mainly ensures the scheduler doesn't error when loading the schedule + + scheduler.shutdown().await.expect("Failed to shutdown"); +} + +/// Test that scheduler loads with empty series thumbnail cron schedule (disabled) +#[tokio::test] +async fn test_scheduler_with_empty_series_thumbnail_cron() { + let (db, _temp_dir) = setup_test_db().await; + + let mut scheduler = Scheduler::new(db.clone()) + .await + .expect("Failed to create scheduler"); + + // Should start successfully without loading series thumbnail job (empty by default) + scheduler.start().await.expect("Failed to start scheduler"); + + // Verify no GenerateSeriesThumbnail tasks were enqueued + let tasks = TaskRepository::list( + &db, + Some("pending".to_string()), + Some("generate_series_thumbnail".to_string()), + Some(100), + ) + .await + .expect("Failed to list tasks"); + + assert_eq!( + tasks.len(), + 0, + "No series thumbnail generation tasks should be enqueued with empty cron" + ); + + scheduler.shutdown().await.expect("Failed to shutdown"); +} + +/// Test that scheduler loads series thumbnail cron when enabled with a valid schedule +#[tokio::test] +async fn test_scheduler_with_series_thumbnail_cron_enabled() { + let (db, _temp_dir) = setup_test_db().await; + + // Create a test user for settings updates + let password_hash = password::hash_password("test123").unwrap(); + let user = create_test_user("test", "test@example.com", &password_hash, true); + let created_user = UserRepository::create(&db, &user).await.unwrap(); + + // Set series thumbnail cron schedule + SettingsRepository::set( + &db, + "thumbnail.series_cron_schedule", + "0 0 4 * * *".to_string(), // Every day at 4:00:00 AM + created_user.id, + None, + None, + ) + .await + .expect("Failed to update setting"); + + let mut scheduler = Scheduler::new(db.clone()) + .await + .expect("Failed to create scheduler"); + + // Should start successfully and load the series thumbnail job + scheduler.start().await.expect("Failed to start scheduler"); + + // Note: We can't easily verify the cron job was added without triggering it + // This test mainly ensures the scheduler doesn't error when loading the schedule + + scheduler.shutdown().await.expect("Failed to shutdown"); +} + +/// Test that both thumbnail cron schedules can be enabled simultaneously +#[tokio::test] +async fn test_scheduler_with_both_thumbnail_crons_enabled() { + let (db, _temp_dir) = setup_test_db().await; + + // Create a test user for settings updates + let password_hash = password::hash_password("test123").unwrap(); + let user = create_test_user("test", "test@example.com", &password_hash, true); + let created_user = UserRepository::create(&db, &user).await.unwrap(); + + // Set both thumbnail cron schedules + SettingsRepository::set( + &db, + "thumbnail.book_cron_schedule", + "0 0 3 * * *".to_string(), // Every day at 3:00:00 AM + created_user.id, + None, + None, + ) + .await + .expect("Failed to update setting"); + + SettingsRepository::set( + &db, + "thumbnail.series_cron_schedule", + "0 0 4 * * *".to_string(), // Every day at 4:00:00 AM + created_user.id, + None, + None, + ) + .await + .expect("Failed to update setting"); + + let mut scheduler = Scheduler::new(db.clone()) + .await + .expect("Failed to create scheduler"); + + // Should start successfully and load both thumbnail jobs + scheduler.start().await.expect("Failed to start scheduler"); + + // This test ensures both cron jobs can be loaded without conflicts + + scheduler.shutdown().await.expect("Failed to shutdown"); +} diff --git a/web/biome.json b/web/biome.json index abe1b8f7..083025bc 100644 --- a/web/biome.json +++ b/web/biome.json @@ -25,6 +25,9 @@ }, "rules": { "recommended": true, + "complexity": { + "noImportantStyles": "off" + }, "style": { "noNonNullAssertion": "off" }, diff --git a/web/openapi.json b/web/openapi.json index eaf04149..1abe1e29 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -7,7 +7,7 @@ "name": "MIT", "url": "https://opensource.org/licenses/MIT" }, - "version": "1.0.0" + "version": "1.0.1" }, "paths": { "/api/v1/admin/cleanup-orphans": { @@ -251,93 +251,84 @@ ] } }, - "/api/v1/admin/settings": { + "/api/v1/admin/plugins": { "get": { "tags": [ - "Settings" - ], - "summary": "List all settings (admin only)", - "operationId": "list_settings", - "parameters": [ - { - "name": "category", - "in": "query", - "description": "Filter by category", - "required": false, - "schema": { - "type": "string" - } - } + "Plugins" ], + "summary": "List all plugins", + "operationId": "list_plugins", "responses": { "200": { - "description": "List of settings", + "description": "Plugins retrieved", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SettingDto" - } + "$ref": "#/components/schemas/PluginsListResponse" } } } }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Forbidden - Admin only" + "description": "PluginsManage permission required" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - } - }, - "/api/v1/admin/settings/bulk": { + }, "post": { "tags": [ - "Settings" + "Plugins" ], - "summary": "Bulk update settings (admin only)", - "operationId": "bulk_update_settings", + "summary": "Create a new plugin", + "description": "Creates a new plugin configuration. If the plugin is created with `enabled: true`,\nan automatic health check is performed to verify connectivity.", + "operationId": "create_plugin", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BulkUpdateSettingsRequest" + "$ref": "#/components/schemas/CreatePluginRequest" } } }, "required": true }, "responses": { - "200": { - "description": "Settings updated", + "201": { + "description": "Plugin created", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SettingDto" - } + "$ref": "#/components/schemas/PluginStatusResponse" } } } }, "400": { - "description": "Invalid value" + "description": "Invalid request" + }, + "401": { + "description": "Unauthorized" }, "403": { - "description": "Forbidden - Admin only" + "description": "PluginsManage permission required" + }, + "409": { + "description": "Plugin with this name already exists" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -345,65 +336,111 @@ ] } }, - "/api/v1/admin/settings/{setting_key}": { + "/api/v1/admin/plugins/{id}": { "get": { "tags": [ - "Settings" + "Plugins" ], - "summary": "Get single setting by key (admin only)", - "operationId": "get_setting", + "summary": "Get a plugin by ID", + "operationId": "get_plugin", "parameters": [ { - "name": "setting_key", + "name": "id", "in": "path", - "description": "Setting key (e.g., scanner.max_concurrent_scans)", + "description": "Plugin ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], "responses": { "200": { - "description": "Setting details", + "description": "Plugin retrieved", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SettingDto" + "$ref": "#/components/schemas/PluginDto" } } } }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "PluginsManage permission required" + }, + "404": { + "description": "Plugin not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "delete": { + "tags": [ + "Plugins" + ], + "summary": "Delete a plugin", + "operationId": "delete_plugin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Plugin ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Plugin deleted" + }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Forbidden - Admin only" + "description": "PluginsManage permission required" }, "404": { - "description": "Setting not found" + "description": "Plugin not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] }, - "put": { + "patch": { "tags": [ - "Settings" + "Plugins" ], - "summary": "Update setting (admin only)", - "operationId": "update_setting", + "summary": "Update a plugin", + "operationId": "update_plugin", "parameters": [ { - "name": "setting_key", + "name": "id", "in": "path", - "description": "Setting key (e.g., scanner.max_concurrent_scans)", + "description": "Plugin ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -411,7 +448,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateSettingRequest" + "$ref": "#/components/schemas/UpdatePluginRequest" } } }, @@ -419,28 +456,31 @@ }, "responses": { "200": { - "description": "Setting updated", + "description": "Plugin updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SettingDto" + "$ref": "#/components/schemas/PluginDto" } } } }, "400": { - "description": "Invalid value" + "description": "Invalid request" + }, + "401": { + "description": "Unauthorized" }, "403": { - "description": "Forbidden - Admin only" + "description": "PluginsManage permission required" }, "404": { - "description": "Setting not found" + "description": "Plugin not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -448,58 +488,49 @@ ] } }, - "/api/v1/admin/settings/{setting_key}/history": { - "get": { + "/api/v1/admin/plugins/{id}/disable": { + "post": { "tags": [ - "Settings" + "Plugins" ], - "summary": "Get setting history (admin only)", - "operationId": "get_setting_history", + "summary": "Disable a plugin", + "operationId": "disable_plugin", "parameters": [ { - "name": "setting_key", + "name": "id", "in": "path", - "description": "Setting key (e.g., scanner.max_concurrent_scans)", + "description": "Plugin ID", "required": true, "schema": { - "type": "string" - } - }, - { - "name": "limit", - "in": "query", - "description": "Maximum number of history entries to return", - "required": false, - "schema": { - "type": "integer", - "format": "int64" + "type": "string", + "format": "uuid" } } ], "responses": { "200": { - "description": "Setting history", + "description": "Plugin disabled", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SettingHistoryDto" - } + "$ref": "#/components/schemas/PluginStatusResponse" } } } }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Forbidden - Admin only" + "description": "PluginsManage permission required" }, "404": { - "description": "Setting not found" + "description": "Plugin not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -507,45 +538,50 @@ ] } }, - "/api/v1/admin/settings/{setting_key}/reset": { + "/api/v1/admin/plugins/{id}/enable": { "post": { "tags": [ - "Settings" + "Plugins" ], - "summary": "Reset setting to default value (admin only)", - "operationId": "reset_setting", + "summary": "Enable a plugin", + "description": "Enables the plugin and automatically performs a health check to verify connectivity.", + "operationId": "enable_plugin", "parameters": [ { - "name": "setting_key", + "name": "id", "in": "path", - "description": "Setting key (e.g., scanner.max_concurrent_scans)", + "description": "Plugin ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], "responses": { "200": { - "description": "Setting reset to default", + "description": "Plugin enabled", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SettingDto" + "$ref": "#/components/schemas/PluginStatusResponse" } } } }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Forbidden - Admin only" + "description": "PluginsManage permission required" }, "404": { - "description": "Setting not found" + "description": "Plugin not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -553,18 +589,29 @@ ] } }, - "/api/v1/admin/sharing-tags": { + "/api/v1/admin/plugins/{id}/failures": { "get": { "tags": [ - "Sharing Tags" + "Plugins" ], - "summary": "List all sharing tags (admin only)", - "operationId": "list_sharing_tags", + "summary": "Get plugin failure history", + "description": "Returns failure events for a plugin, including time-window statistics.", + "operationId": "get_plugin_failures", "parameters": [ { - "name": "page", + "name": "id", + "in": "path", + "description": "Plugin ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", "in": "query", - "description": "Page number (1-indexed, default 1)", + "description": "Maximum number of failures to return", "required": false, "schema": { "type": "integer", @@ -573,9 +620,9 @@ } }, { - "name": "pageSize", + "name": "offset", "in": "query", - "description": "Number of items per page (default 50, max 500)", + "description": "Number of failures to skip", "required": false, "schema": { "type": "integer", @@ -586,65 +633,78 @@ ], "responses": { "200": { - "description": "List of sharing tags", + "description": "Failures retrieved", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse_SharingTagDto" + "$ref": "#/components/schemas/PluginFailuresResponse" } } } }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Forbidden - Missing permission" + "description": "PluginsManage permission required" + }, + "404": { + "description": "Plugin not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - }, - "post": { + } + }, + "/api/v1/admin/plugins/{id}/health": { + "get": { "tags": [ - "Sharing Tags" + "Plugins" ], - "summary": "Create a new sharing tag (admin only)", - "operationId": "create_sharing_tag", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateSharingTagRequest" - } + "summary": "Get plugin health information", + "operationId": "get_plugin_health", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Plugin ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" } - }, - "required": true - }, + } + ], "responses": { - "201": { - "description": "Sharing tag created", + "200": { + "description": "Health information retrieved", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SharingTagDto" + "$ref": "#/components/schemas/PluginHealthResponse" } } } }, - "400": { - "description": "Invalid request or tag name already exists" + "401": { + "description": "Unauthorized" }, "403": { - "description": "Forbidden - Missing permission" + "description": "PluginsManage permission required" + }, + "404": { + "description": "Plugin not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -652,18 +712,19 @@ ] } }, - "/api/v1/admin/sharing-tags/{tag_id}": { - "get": { + "/api/v1/admin/plugins/{id}/reset": { + "post": { "tags": [ - "Sharing Tags" + "Plugins" ], - "summary": "Get a sharing tag by ID (admin only)", - "operationId": "get_sharing_tag", + "summary": "Reset plugin failure count", + "description": "Clears the failure count and disabled reason, allowing the plugin to be used again.", + "operationId": "reset_plugin_failures", "parameters": [ { - "name": "tag_id", + "name": "id", "in": "path", - "description": "Sharing tag ID", + "description": "Plugin ID", "required": true, "schema": { "type": "string", @@ -673,80 +734,48 @@ ], "responses": { "200": { - "description": "Sharing tag details", + "description": "Failure count reset", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SharingTagDto" + "$ref": "#/components/schemas/PluginStatusResponse" } } } }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Forbidden - Missing permission" + "description": "PluginsManage permission required" }, "404": { - "description": "Sharing tag not found" + "description": "Plugin not found" } }, "security": [ { - "jwt_bearer": [] - }, - { - "api_key": [] - } - ] - }, - "delete": { - "tags": [ - "Sharing Tags" - ], - "summary": "Delete a sharing tag (admin only)", - "operationId": "delete_sharing_tag", - "parameters": [ - { - "name": "tag_id", - "in": "path", - "description": "Sharing tag ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "204": { - "description": "Sharing tag deleted" - }, - "403": { - "description": "Forbidden - Missing permission" - }, - "404": { - "description": "Sharing tag not found" - } - }, - "security": [ - { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - }, - "patch": { + } + }, + "/api/v1/admin/plugins/{id}/test": { + "post": { "tags": [ - "Sharing Tags" + "Plugins" ], - "summary": "Update a sharing tag (admin only)", - "operationId": "update_sharing_tag", + "summary": "Test a plugin connection", + "description": "Spawns the plugin process, sends an initialize request, and returns the manifest.", + "operationId": "test_plugin", "parameters": [ { - "name": "tag_id", + "name": "id", "in": "path", - "description": "Sharing tag ID", + "description": "Plugin ID", "required": true, "schema": { "type": "string", @@ -754,40 +783,30 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateSharingTagRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Sharing tag updated", + "description": "Test completed", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SharingTagDto" + "$ref": "#/components/schemas/PluginTestResult" } } } }, - "400": { - "description": "Invalid request or tag name already exists" + "401": { + "description": "Unauthorized" }, "403": { - "description": "Forbidden - Missing permission" + "description": "PluginsManage permission required" }, "404": { - "description": "Sharing tag not found" + "description": "Plugin not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -795,50 +814,40 @@ ] } }, - "/api/v1/api-keys": { + "/api/v1/admin/settings": { "get": { "tags": [ - "API Keys" + "Settings" ], - "summary": "List API keys for the authenticated user\nUsers can only see their own keys unless they are admin", - "operationId": "list_api_keys", + "summary": "List all settings (admin only)", + "operationId": "list_settings", "parameters": [ { - "name": "page", - "in": "query", - "description": "Page number (1-indexed, default 1)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, - { - "name": "pageSize", + "name": "category", "in": "query", - "description": "Number of items per page (default 50, max 500)", + "description": "Filter by category", "required": false, "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string" } } ], "responses": { "200": { - "description": "List of API keys", + "description": "List of settings", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse_ApiKeyDto" + "type": "array", + "items": { + "$ref": "#/components/schemas/SettingDto" + } } } } }, "403": { - "description": "Forbidden - Missing permission" + "description": "Forbidden - Admin only" } }, "security": [ @@ -849,39 +858,44 @@ "api_key": [] } ] - }, + } + }, + "/api/v1/admin/settings/bulk": { "post": { "tags": [ - "API Keys" + "Settings" ], - "summary": "Create a new API key\nAPI keys are always associated with the authenticated user", - "operationId": "create_api_key", + "summary": "Bulk update settings (admin only)", + "operationId": "bulk_update_settings", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateApiKeyRequest" + "$ref": "#/components/schemas/BulkUpdateSettingsRequest" } } }, "required": true }, "responses": { - "201": { - "description": "API key created", + "200": { + "description": "Settings updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateApiKeyResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/SettingDto" + } } } } }, "400": { - "description": "Invalid request" + "description": "Invalid value" }, "403": { - "description": "Forbidden - Missing permission" + "description": "Forbidden - Admin only" } }, "security": [ @@ -894,41 +908,40 @@ ] } }, - "/api/v1/api-keys/{api_key_id}": { + "/api/v1/admin/settings/{setting_key}": { "get": { "tags": [ - "API Keys" + "Settings" ], - "summary": "Get API key by ID\nUsers can only get their own keys unless they are admin", - "operationId": "get_api_key", + "summary": "Get single setting by key (admin only)", + "operationId": "get_setting", "parameters": [ { - "name": "api_key_id", + "name": "setting_key", "in": "path", - "description": "API key ID", + "description": "Setting key (e.g., scanner.max_concurrent_scans)", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], "responses": { "200": { - "description": "API key details", + "description": "Setting details", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiKeyDto" + "$ref": "#/components/schemas/SettingDto" } } } }, "403": { - "description": "Forbidden - Missing permission or not owner" + "description": "Forbidden - Admin only" }, "404": { - "description": "API key not found" + "description": "Setting not found" } }, "security": [ @@ -940,33 +953,52 @@ } ] }, - "delete": { + "put": { "tags": [ - "API Keys" + "Settings" ], - "summary": "Delete an API key\nUsers can only delete their own keys unless they are admin", - "operationId": "delete_api_key", + "summary": "Update setting (admin only)", + "operationId": "update_setting", "parameters": [ { - "name": "api_key_id", + "name": "setting_key", "in": "path", - "description": "API key ID", + "description": "Setting key (e.g., scanner.max_concurrent_scans)", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSettingRequest" + } + } + }, + "required": true + }, "responses": { - "204": { - "description": "API key deleted" + "200": { + "description": "Setting updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SettingDto" + } + } + } + }, + "400": { + "description": "Invalid value" }, "403": { - "description": "Forbidden - Missing permission or not owner" + "description": "Forbidden - Admin only" }, "404": { - "description": "API key not found" + "description": "Setting not found" } }, "security": [ @@ -977,51 +1009,55 @@ "api_key": [] } ] - }, - "patch": { + } + }, + "/api/v1/admin/settings/{setting_key}/history": { + "get": { "tags": [ - "API Keys" + "Settings" ], - "summary": "Update an API key (partial update)\nUsers can only update their own keys unless they are admin", - "operationId": "update_api_key", + "summary": "Get setting history (admin only)", + "operationId": "get_setting_history", "parameters": [ { - "name": "api_key_id", + "name": "setting_key", "in": "path", - "description": "API key ID", + "description": "Setting key (e.g., scanner.max_concurrent_scans)", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of history entries to return", + "required": false, + "schema": { + "type": "integer", + "format": "int64" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateApiKeyRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "API key updated", + "description": "Setting history", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiKeyDto" + "type": "array", + "items": { + "$ref": "#/components/schemas/SettingHistoryDto" + } } } } }, "403": { - "description": "Forbidden - Missing permission or not owner" + "description": "Forbidden - Admin only" }, "404": { - "description": "API key not found" + "description": "Setting not found" } }, "security": [ @@ -1034,52 +1070,40 @@ ] } }, - "/api/v1/auth/login": { + "/api/v1/admin/settings/{setting_key}/reset": { "post": { "tags": [ - "Auth" + "Settings" ], - "summary": "Login handler", - "description": "Authenticates a user with username/email and password, returns JWT token", - "operationId": "login", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginRequest" - } + "summary": "Reset setting to default value (admin only)", + "operationId": "reset_setting", + "parameters": [ + { + "name": "setting_key", + "in": "path", + "description": "Setting key (e.g., scanner.max_concurrent_scans)", + "required": true, + "schema": { + "type": "string" } - }, - "required": true - }, + } + ], "responses": { "200": { - "description": "Login successful", + "description": "Setting reset to default", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LoginResponse" + "$ref": "#/components/schemas/SettingDto" } } } }, - "401": { - "description": "Invalid credentials" - } - } - } - }, - "/api/v1/auth/logout": { - "post": { - "tags": [ - "Auth" - ], - "summary": "Logout handler", - "description": "No-op for stateless JWT - client should discard token", - "operationId": "logout", - "responses": { - "200": { - "description": "Logout successful" + "403": { + "description": "Forbidden - Admin only" + }, + "404": { + "description": "Setting not found" } }, "security": [ @@ -1092,205 +1116,140 @@ ] } }, - "/api/v1/auth/register": { - "post": { + "/api/v1/admin/sharing-tags": { + "get": { "tags": [ - "Auth" + "Sharing Tags" ], - "summary": "Register handler", - "description": "Creates a new user account with username, email, and password", - "operationId": "register", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegisterRequest" - } + "summary": "List all sharing tags (admin only)", + "operationId": "list_sharing_tags", + "parameters": [ + { + "name": "page", + "in": "query", + "description": "Page number (1-indexed, default 1)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 } }, - "required": true - }, - "responses": { - "201": { - "description": "User registered successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegisterResponse" - } - } + { + "name": "pageSize", + "in": "query", + "description": "Number of items per page (default 50, max 500)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 } - }, - "400": { - "description": "Invalid request or user already exists" - }, - "422": { - "description": "Validation error" } - } - } - }, - "/api/v1/auth/resend-verification": { - "post": { - "tags": [ - "Auth" ], - "summary": "Resend verification email handler", - "description": "Resends the verification email to a user", - "operationId": "resend_verification", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ResendVerificationRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Verification email sent", + "description": "List of sharing tags", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResendVerificationResponse" + "$ref": "#/components/schemas/PaginatedResponse_SharingTagDto" } } } }, - "400": { - "description": "Invalid request or email already verified" + "403": { + "description": "Forbidden - Missing permission" } - } - } - }, - "/api/v1/auth/verify-email": { + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + }, "post": { "tags": [ - "Auth" + "Sharing Tags" ], - "summary": "Verify email handler", - "description": "Verifies a user's email address using the token sent via email", - "operationId": "verify_email", + "summary": "Create a new sharing tag (admin only)", + "operationId": "create_sharing_tag", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VerifyEmailRequest" + "$ref": "#/components/schemas/CreateSharingTagRequest" } } }, "required": true }, "responses": { - "200": { - "description": "Email verified successfully", + "201": { + "description": "Sharing tag created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VerifyEmailResponse" + "$ref": "#/components/schemas/SharingTagDto" } } } }, "400": { - "description": "Invalid or expired token" + "description": "Invalid request or tag name already exists" + }, + "403": { + "description": "Forbidden - Missing permission" } - } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] } }, - "/api/v1/books": { + "/api/v1/admin/sharing-tags/{tag_id}": { "get": { "tags": [ - "Books" + "Sharing Tags" ], - "summary": "List books with pagination", - "operationId": "list_books", + "summary": "Get a sharing tag by ID (admin only)", + "operationId": "get_sharing_tag", "parameters": [ { - "name": "libraryId", - "in": "query", - "description": "Optional library filter", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "seriesId", - "in": "query", - "description": "Optional series filter", - "required": false, + "name": "tag_id", + "in": "path", + "description": "Sharing tag ID", + "required": true, "schema": { - "type": [ - "string", - "null" - ], + "type": "string", "format": "uuid" } - }, - { - "name": "page", - "in": "query", - "description": "Page number (1-indexed, minimum 1)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, - { - "name": "pageSize", - "in": "query", - "description": "Number of items per page (max 100, default 50)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, - { - "name": "sort", - "in": "query", - "description": "Sort parameter (format: \"field,direction\" e.g. \"title,asc\")", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "full", - "in": "query", - "description": "Return full data including metadata and locks.\nDefault is false for backward compatibility.", - "required": false, - "schema": { - "type": "boolean" - } } ], "responses": { "200": { - "description": "Paginated list of books (returns FullBookListResponse when full=true)", + "description": "Sharing tag details", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse" + "$ref": "#/components/schemas/SharingTagDto" } } } }, "403": { - "description": "Forbidden" + "description": "Forbidden - Missing permission" + }, + "404": { + "description": "Sharing tag not found" } }, "security": [ @@ -1301,113 +1260,92 @@ "api_key": [] } ] - } - }, - "/api/v1/books/errors": { - "get": { + }, + "delete": { "tags": [ - "Books" + "Sharing Tags" ], - "summary": "List books with errors (v2 - grouped by error type)", - "description": "Returns books with errors grouped by error type, with counts and pagination.\nThis enhanced endpoint provides detailed error information including error\ntypes, messages, and timestamps.", - "operationId": "list_books_with_errors_v2", + "summary": "Delete a sharing tag (admin only)", + "operationId": "delete_sharing_tag", "parameters": [ { - "name": "libraryId", - "in": "query", - "description": "Optional library filter", - "required": false, + "name": "tag_id", + "in": "path", + "description": "Sharing tag ID", + "required": true, "schema": { - "type": [ - "string", - "null" - ], + "type": "string", "format": "uuid" } + } + ], + "responses": { + "204": { + "description": "Sharing tag deleted" }, - { - "name": "seriesId", - "in": "query", - "description": "Optional series filter", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } + "403": { + "description": "Forbidden - Missing permission" }, + "404": { + "description": "Sharing tag not found" + } + }, + "security": [ { - "name": "errorType", - "in": "query", - "description": "Filter by specific error type", - "required": false, - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/BookErrorTypeDto" - } - ] - } + "jwt_bearer": [] }, { - "name": "page", - "in": "query", - "description": "Page number (1-indexed, default 1)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, + "api_key": [] + } + ] + }, + "patch": { + "tags": [ + "Sharing Tags" + ], + "summary": "Update a sharing tag (admin only)", + "operationId": "update_sharing_tag", + "parameters": [ { - "name": "pageSize", - "in": "query", - "description": "Number of items per page (max 100, default 50)", - "required": false, + "name": "tag_id", + "in": "path", + "description": "Sharing tag ID", + "required": true, "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string", + "format": "uuid" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSharingTagRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Books with errors grouped by type", + "description": "Sharing tag updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BooksWithErrorsResponse" - }, - "example": { - "errorCounts": { - "parser": 5, - "thumbnail": 10 - }, - "groups": [ - { - "books": [], - "count": 5, - "errorType": "parser", - "label": "Parser Error" - } - ], - "page": 0, - "pageSize": 20, - "totalBooksWithErrors": 15, - "totalPages": 1 + "$ref": "#/components/schemas/SharingTagDto" } } } }, + "400": { + "description": "Invalid request or tag name already exists" + }, "403": { - "description": "Forbidden" + "description": "Forbidden - Missing permission" + }, + "404": { + "description": "Sharing tag not found" } }, "security": [ @@ -1420,44 +1358,18 @@ ] } }, - "/api/v1/books/in-progress": { + "/api/v1/api-keys": { "get": { "tags": [ - "Books" + "API Keys" ], - "summary": "List books with reading progress (in-progress books)", - "operationId": "list_in_progress_books", + "summary": "List API keys for the authenticated user\nUsers can only see their own keys unless they are admin", + "operationId": "list_api_keys", "parameters": [ - { - "name": "libraryId", - "in": "query", - "description": "Optional library filter", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "seriesId", - "in": "query", - "description": "Optional series filter", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, { "name": "page", "in": "query", - "description": "Page number (1-indexed, minimum 1)", + "description": "Page number (1-indexed, default 1)", "required": false, "schema": { "type": "integer", @@ -1468,49 +1380,71 @@ { "name": "pageSize", "in": "query", - "description": "Number of items per page (max 100, default 50)", + "description": "Number of items per page (default 50, max 500)", "required": false, "schema": { "type": "integer", "format": "int64", "minimum": 0 } + } + ], + "responses": { + "200": { + "description": "List of API keys", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse_ApiKeyDto" + } + } + } }, + "403": { + "description": "Forbidden - Missing permission" + } + }, + "security": [ { - "name": "sort", - "in": "query", - "description": "Sort parameter (format: \"field,direction\" e.g. \"title,asc\")", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } + "jwt_bearer": [] }, { - "name": "full", - "in": "query", - "description": "Return full data including metadata and locks.\nDefault is false for backward compatibility.", - "required": false, - "schema": { - "type": "boolean" - } + "api_key": [] } + ] + }, + "post": { + "tags": [ + "API Keys" ], + "summary": "Create a new API key\nAPI keys are always associated with the authenticated user", + "operationId": "create_api_key", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateApiKeyRequest" + } + } + }, + "required": true + }, "responses": { - "200": { - "description": "Paginated list of in-progress books", + "201": { + "description": "API key created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse" + "$ref": "#/components/schemas/CreateApiKeyResponse" } } } }, + "400": { + "description": "Invalid request" + }, "403": { - "description": "Forbidden" + "description": "Forbidden - Missing permission" } }, "security": [ @@ -1523,59 +1457,105 @@ ] } }, - "/api/v1/books/list": { - "post": { + "/api/v1/api-keys/{api_key_id}": { + "get": { "tags": [ - "Books" + "API Keys" ], - "summary": "List books with advanced filtering", - "description": "Supports complex filter conditions including nested AllOf/AnyOf logic,\ngenre/tag filtering with include/exclude, and more.\n\nPagination parameters (page, pageSize, sort, full) are passed as query parameters.\nFilter conditions are passed in the request body.", - "operationId": "list_books_filtered", + "summary": "Get API key by ID\nUsers can only get their own keys unless they are admin", + "operationId": "get_api_key", "parameters": [ { - "name": "page", - "in": "query", - "description": "Page number (1-indexed, minimum 1)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "default": 1, - "minimum": 1 + "name": "api_key_id", + "in": "path", + "description": "API key ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "API key details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyDto" + } + } } }, + "403": { + "description": "Forbidden - Missing permission or not owner" + }, + "404": { + "description": "API key not found" + } + }, + "security": [ { - "name": "pageSize", - "in": "query", - "description": "Number of items per page (max 500, default 50)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "default": 50, - "maximum": 500, - "minimum": 1 - } + "jwt_bearer": [] }, { - "name": "sort", - "in": "query", - "description": "Sort field and direction (e.g., \"name,asc\" or \"createdAt,desc\")", - "required": false, + "api_key": [] + } + ] + }, + "delete": { + "tags": [ + "API Keys" + ], + "summary": "Delete an API key\nUsers can only delete their own keys unless they are admin", + "operationId": "delete_api_key", + "parameters": [ + { + "name": "api_key_id", + "in": "path", + "description": "API key ID", + "required": true, "schema": { - "type": [ - "string", - "null" - ] + "type": "string", + "format": "uuid" } + } + ], + "responses": { + "204": { + "description": "API key deleted" + }, + "403": { + "description": "Forbidden - Missing permission or not owner" + }, + "404": { + "description": "API key not found" + } + }, + "security": [ + { + "jwt_bearer": [] }, { - "name": "full", - "in": "query", - "description": "Return full data including metadata, locks, and related entities.\nDefault is false for backward compatibility.", - "required": false, + "api_key": [] + } + ] + }, + "patch": { + "tags": [ + "API Keys" + ], + "summary": "Update an API key (partial update)\nUsers can only update their own keys unless they are admin", + "operationId": "update_api_key", + "parameters": [ + { + "name": "api_key_id", + "in": "path", + "description": "API key ID", + "required": true, "schema": { - "type": "boolean" + "type": "string", + "format": "uuid" } } ], @@ -1583,7 +1563,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookListRequest" + "$ref": "#/components/schemas/UpdateApiKeyRequest" } } }, @@ -1591,17 +1571,20 @@ }, "responses": { "200": { - "description": "Paginated list of filtered books (returns FullBookListResponse when full=true)", + "description": "API key updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse" + "$ref": "#/components/schemas/ApiKeyDto" } } } }, "403": { - "description": "Forbidden" + "description": "Forbidden - Missing permission or not owner" + }, + "404": { + "description": "API key not found" } }, "security": [ @@ -1614,97 +1597,52 @@ ] } }, - "/api/v1/books/on-deck": { - "get": { + "/api/v1/auth/login": { + "post": { "tags": [ - "Books" + "Auth" ], - "summary": "List on-deck books (next unread book in series where user has completed at least one book)", - "operationId": "list_on_deck_books", - "parameters": [ - { - "name": "libraryId", - "in": "query", - "description": "Optional library filter", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "seriesId", - "in": "query", - "description": "Optional series filter", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "page", - "in": "query", - "description": "Page number (1-indexed, minimum 1)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, - { - "name": "pageSize", - "in": "query", - "description": "Number of items per page (max 100, default 50)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, - { - "name": "sort", - "in": "query", - "description": "Sort parameter (format: \"field,direction\" e.g. \"title,asc\")", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] + "summary": "Login handler", + "description": "Authenticates a user with username/email and password, returns JWT token", + "operationId": "login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } } }, - { - "name": "full", - "in": "query", - "description": "Return full data including metadata and locks.\nDefault is false for backward compatibility.", - "required": false, - "schema": { - "type": "boolean" - } - } - ], + "required": true + }, "responses": { "200": { - "description": "Paginated list of on-deck books", + "description": "Login successful", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse" + "$ref": "#/components/schemas/LoginResponse" } } } }, - "403": { - "description": "Forbidden" + "401": { + "description": "Invalid credentials" + } + } + } + }, + "/api/v1/auth/logout": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Logout handler", + "description": "No-op for stateless JWT - client should discard token", + "operationId": "logout", + "responses": { + "200": { + "description": "Logout successful" } }, "security": [ @@ -1717,33 +1655,141 @@ ] } }, - "/api/v1/books/recently-added": { - "get": { + "/api/v1/auth/register": { + "post": { "tags": [ - "Books" + "Auth" ], - "summary": "List recently added books", - "operationId": "list_recently_added_books", - "parameters": [ - { - "name": "libraryId", - "in": "query", - "description": "Optional library filter", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "seriesId", - "in": "query", - "description": "Optional series filter", - "required": false, - "schema": { + "summary": "Register handler", + "description": "Creates a new user account with username, email, and password", + "operationId": "register", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "User registered successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterResponse" + } + } + } + }, + "400": { + "description": "Invalid request or user already exists" + }, + "422": { + "description": "Validation error" + } + } + } + }, + "/api/v1/auth/resend-verification": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Resend verification email handler", + "description": "Resends the verification email to a user", + "operationId": "resend_verification", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResendVerificationRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Verification email sent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResendVerificationResponse" + } + } + } + }, + "400": { + "description": "Invalid request or email already verified" + } + } + } + }, + "/api/v1/auth/verify-email": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Verify email handler", + "description": "Verifies a user's email address using the token sent via email", + "operationId": "verify_email", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyEmailRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Email verified successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyEmailResponse" + } + } + } + }, + "400": { + "description": "Invalid or expired token" + } + } + } + }, + "/api/v1/books": { + "get": { + "tags": [ + "Books" + ], + "summary": "List books with pagination", + "operationId": "list_books", + "parameters": [ + { + "name": "libraryId", + "in": "query", + "description": "Optional library filter", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + }, + { + "name": "seriesId", + "in": "query", + "description": "Optional series filter", + "required": false, + "schema": { "type": [ "string", "null" @@ -1797,7 +1843,7 @@ ], "responses": { "200": { - "description": "Paginated list of recently added books", + "description": "Paginated list of books (returns FullBookListResponse when full=true)", "content": { "application/json": { "schema": { @@ -1820,60 +1866,91 @@ ] } }, - "/api/v1/books/recently-read": { - "get": { + "/api/v1/books/bulk/analyze": { + "post": { "tags": [ - "Books" + "Bulk Operations" ], - "summary": "List recently read books (ordered by last read activity)", - "operationId": "list_recently_read_books", - "parameters": [ - { - "name": "limit", - "in": "query", - "description": "Maximum number of books to return (default: 50)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "summary": "Bulk analyze multiple books", + "description": "Enqueues analysis tasks for all specified books.\nBooks that don't exist are silently skipped.", + "operationId": "bulk_analyze_books", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkAnalyzeBooksRequest" + } } }, - { - "name": "libraryId", - "in": "query", - "description": "Filter by library ID (optional)", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" + "required": true + }, + "responses": { + "200": { + "description": "Analysis tasks enqueued", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkAnalyzeResponse" + } + } } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] } + ] + } + }, + "/api/v1/books/bulk/read": { + "post": { + "tags": [ + "Bulk Operations" ], + "summary": "Bulk mark multiple books as read", + "description": "Marks all specified books as read for the authenticated user.\nBooks that don't exist are silently skipped.", + "operationId": "bulk_mark_books_as_read", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkBooksRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "List of recently read books", + "description": "Books marked as read", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BookDto" - } + "$ref": "#/components/schemas/MarkReadResponse" } } } }, + "401": { + "description": "Unauthorized" + }, "403": { "description": "Forbidden" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -1881,23 +1958,19 @@ ] } }, - "/api/v1/books/retry-all-errors": { + "/api/v1/books/bulk/unread": { "post": { "tags": [ - "Books" + "Bulk Operations" ], - "summary": "Retry all failed operations across all books", - "description": "Enqueues appropriate tasks for all books with errors.\nCan be filtered by error type or library.", - "operationId": "retry_all_book_errors", + "summary": "Bulk mark multiple books as unread", + "description": "Marks all specified books as unread for the authenticated user.\nBooks that don't exist or have no progress are silently skipped.", + "operationId": "bulk_mark_books_as_unread", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RetryAllErrorsRequest" - }, - "example": { - "errorType": "parser", - "libraryId": null + "$ref": "#/components/schemas/BulkBooksRequest" } } }, @@ -1905,26 +1978,25 @@ }, "responses": { "200": { - "description": "Retry tasks enqueued", + "description": "Books marked as unread", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RetryErrorsResponse" - }, - "example": { - "message": "Enqueued 10 analysis tasks and 5 thumbnail tasks", - "tasksEnqueued": 15 + "$ref": "#/components/schemas/MarkReadResponse" } } } }, + "401": { + "description": "Unauthorized" + }, "403": { "description": "Forbidden" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -1932,12 +2004,13 @@ ] } }, - "/api/v1/books/with-errors": { + "/api/v1/books/errors": { "get": { "tags": [ "Books" ], - "summary": "List books with analysis errors", + "summary": "List books with errors (grouped by error type)", + "description": "Returns books with errors grouped by error type, with counts and pagination.\nThis endpoint provides detailed error information including error\ntypes, messages, and timestamps.", "operationId": "list_books_with_errors", "parameters": [ { @@ -1966,10 +2039,26 @@ "format": "uuid" } }, + { + "name": "errorType", + "in": "query", + "description": "Filter by specific error type", + "required": false, + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/BookErrorTypeDto" + } + ] + } + }, { "name": "page", "in": "query", - "description": "Page number (1-indexed, minimum 1)", + "description": "Page number (1-indexed, default 1)", "required": false, "schema": { "type": "integer", @@ -1991,12 +2080,30 @@ ], "responses": { "200": { - "description": "Paginated list of books with analysis errors", + "description": "Books with errors grouped by type", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse" - } + "$ref": "#/components/schemas/BooksWithErrorsResponse" + }, + "example": { + "errorCounts": { + "parser": 5, + "thumbnail": 10 + }, + "groups": [ + { + "books": [], + "count": 5, + "errorType": "parser", + "label": "Parser Error" + } + ], + "page": 0, + "pageSize": 20, + "totalBooksWithErrors": 15, + "totalPages": 1 + } } } }, @@ -2014,24 +2121,74 @@ ] } }, - "/api/v1/books/{book_id}": { + "/api/v1/books/in-progress": { "get": { "tags": [ "Books" ], - "summary": "Get book by ID", - "operationId": "get_book", + "summary": "List books with reading progress (in-progress books)", + "operationId": "list_in_progress_books", "parameters": [ { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, + "name": "libraryId", + "in": "query", + "description": "Optional library filter", + "required": false, "schema": { - "type": "string", + "type": [ + "string", + "null" + ], + "format": "uuid" + } + }, + { + "name": "seriesId", + "in": "query", + "description": "Optional series filter", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], "format": "uuid" } }, + { + "name": "page", + "in": "query", + "description": "Page number (1-indexed, minimum 1)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "pageSize", + "in": "query", + "description": "Number of items per page (max 100, default 50)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "sort", + "in": "query", + "description": "Sort parameter (format: \"field,direction\" e.g. \"title,asc\")", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, { "name": "full", "in": "query", @@ -2044,17 +2201,17 @@ ], "responses": { "200": { - "description": "Book details (returns FullBookResponse when full=true)", + "description": "Paginated list of in-progress books", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookDetailResponse" + "$ref": "#/components/schemas/PaginatedResponse" } } } }, - "404": { - "description": "Book not found" + "403": { + "description": "Forbidden" } }, "security": [ @@ -2067,39 +2224,85 @@ ] } }, - "/api/v1/books/{book_id}/adjacent": { - "get": { + "/api/v1/books/list": { + "post": { "tags": [ "Books" ], - "summary": "Get adjacent books in the same series", - "description": "Returns the previous and next books relative to the requested book,\nordered by book number within the series.", - "operationId": "get_adjacent_books", + "summary": "List books with advanced filtering", + "description": "Supports complex filter conditions including nested AllOf/AnyOf logic,\ngenre/tag filtering with include/exclude, and more.\n\nPagination parameters (page, pageSize, sort, full) are passed as query parameters.\nFilter conditions are passed in the request body.", + "operationId": "list_books_filtered", "parameters": [ { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, + "name": "page", + "in": "query", + "description": "Page number (1-indexed, minimum 1)", + "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "format": "int64", + "default": 1, + "minimum": 1 + } + }, + { + "name": "pageSize", + "in": "query", + "description": "Number of items per page (max 500, default 50)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "default": 50, + "maximum": 500, + "minimum": 1 + } + }, + { + "name": "sort", + "in": "query", + "description": "Sort field and direction (e.g., \"name,asc\" or \"createdAt,desc\")", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "full", + "in": "query", + "description": "Return full data including metadata, locks, and related entities.\nDefault is false for backward compatibility.", + "required": false, + "schema": { + "type": "boolean" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookListRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Adjacent books", + "description": "Paginated list of filtered books (returns FullBookListResponse when full=true)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdjacentBooksResponse" + "$ref": "#/components/schemas/PaginatedResponse" } } } }, - "404": { - "description": "Book not found" + "403": { + "description": "Forbidden" } }, "security": [ @@ -2112,47 +2315,102 @@ ] } }, - "/api/v1/books/{book_id}/analyze": { - "post": { + "/api/v1/books/on-deck": { + "get": { "tags": [ - "Scans" + "Books" ], - "summary": "Trigger analysis of a single book (force reanalysis)", - "description": "# Permission Required\n- `books:write`\n\n# Behavior\nEnqueues an AnalyzeBook task with force=true.\nReturns immediately with a task_id to track progress.", - "operationId": "trigger_book_analysis", + "summary": "List on-deck books (next unread book in series where user has completed at least one book)", + "operationId": "list_on_deck_books", "parameters": [ { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, + "name": "libraryId", + "in": "query", + "description": "Optional library filter", + "required": false, "schema": { - "type": "string", + "type": [ + "string", + "null" + ], + "format": "uuid" + } + }, + { + "name": "seriesId", + "in": "query", + "description": "Optional series filter", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], "format": "uuid" } + }, + { + "name": "page", + "in": "query", + "description": "Page number (1-indexed, minimum 1)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "pageSize", + "in": "query", + "description": "Number of items per page (max 100, default 50)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "sort", + "in": "query", + "description": "Sort parameter (format: \"field,direction\" e.g. \"title,asc\")", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "full", + "in": "query", + "description": "Return full data including metadata and locks.\nDefault is false for backward compatibility.", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { "200": { - "description": "Analysis task enqueued successfully", + "description": "Paginated list of on-deck books", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTaskResponse" + "$ref": "#/components/schemas/PaginatedResponse" } } } }, "403": { - "description": "Permission denied" - }, - "404": { - "description": "Book not found" + "description": "Forbidden" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -2160,50 +2418,102 @@ ] } }, - "/api/v1/books/{book_id}/analyze-unanalyzed": { - "post": { + "/api/v1/books/recently-added": { + "get": { "tags": [ - "Scans" + "Books" ], - "summary": "Trigger analysis of a book if not already analyzed", - "description": "# Permission Required\n- `books:write`\n\n# Behavior\nEnqueues an AnalyzeBook task with force=false if the book has not been analyzed yet.\nReturns 400 if the book is already analyzed.\nReturns immediately with a task_id to track progress.", - "operationId": "trigger_book_unanalyzed_analysis", + "summary": "List recently added books", + "operationId": "list_recently_added_books", "parameters": [ { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, + "name": "libraryId", + "in": "query", + "description": "Optional library filter", + "required": false, "schema": { - "type": "string", + "type": [ + "string", + "null" + ], + "format": "uuid" + } + }, + { + "name": "seriesId", + "in": "query", + "description": "Optional series filter", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], "format": "uuid" } + }, + { + "name": "page", + "in": "query", + "description": "Page number (1-indexed, minimum 1)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "pageSize", + "in": "query", + "description": "Number of items per page (max 100, default 50)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "sort", + "in": "query", + "description": "Sort parameter (format: \"field,direction\" e.g. \"title,asc\")", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "full", + "in": "query", + "description": "Return full data including metadata and locks.\nDefault is false for backward compatibility.", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { "200": { - "description": "Analysis task enqueued successfully", + "description": "Paginated list of recently added books", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTaskResponse" + "$ref": "#/components/schemas/PaginatedResponse" } } } }, - "400": { - "description": "Book is already analyzed" - }, "403": { - "description": "Permission denied" - }, - "404": { - "description": "Book not found" + "description": "Forbidden" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -2211,38 +2521,55 @@ ] } }, - "/api/v1/books/{book_id}/file": { + "/api/v1/books/recently-read": { "get": { "tags": [ "Books" ], - "summary": "Download book file", - "description": "Streams the original book file (CBZ, CBR, EPUB, PDF) for download.\nUsed by OPDS clients for acquisition links.", - "operationId": "get_book_file", + "summary": "List recently read books (ordered by last read activity)", + "operationId": "list_recently_read_books", "parameters": [ { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, + "name": "limit", + "in": "query", + "description": "Maximum number of books to return (default: 50)", + "required": false, "schema": { - "type": "string", + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "libraryId", + "in": "query", + "description": "Filter by library ID (optional)", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], "format": "uuid" } } ], "responses": { "200": { - "description": "Book file", + "description": "List of recently read books", "content": { - "application/octet-stream": {} + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookDto" + } + } + } } }, "403": { "description": "Forbidden" - }, - "404": { - "description": "Book not found" } }, "security": [ @@ -2255,31 +2582,23 @@ ] } }, - "/api/v1/books/{book_id}/metadata": { - "put": { + "/api/v1/books/retry-all-errors": { + "post": { "tags": [ "Books" ], - "summary": "Replace all book metadata (PUT)", - "description": "Completely replaces all metadata fields. Omitted or null fields will be cleared.\nIf no metadata record exists, one will be created.", - "operationId": "replace_book_metadata", - "parameters": [ - { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], + "summary": "Retry all failed operations across all books", + "description": "Enqueues appropriate tasks for all books with errors.\nCan be filtered by error type or library.", + "operationId": "retry_all_book_errors", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReplaceBookMetadataRequest" + "$ref": "#/components/schemas/RetryAllErrorsRequest" + }, + "example": { + "errorType": "parser", + "libraryId": null } } }, @@ -2287,20 +2606,21 @@ }, "responses": { "200": { - "description": "Metadata replaced successfully", + "description": "Retry tasks enqueued", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookMetadataResponse" + "$ref": "#/components/schemas/RetryErrorsResponse" + }, + "example": { + "message": "Enqueued 10 analysis tasks and 5 thumbnail tasks", + "tasksEnqueued": 15 } } } }, "403": { "description": "Forbidden" - }, - "404": { - "description": "Book not found" } }, "security": [ @@ -2311,31 +2631,21 @@ "api_key": [] } ] - }, - "patch": { + } + }, + "/api/v1/books/thumbnails/generate": { + "post": { "tags": [ - "Books" - ], - "summary": "Partially update book metadata (PATCH)", - "description": "Only provided fields will be updated. Absent fields are unchanged.\nExplicitly null fields will be cleared.\nIf no metadata record exists, one will be created with the provided fields.", - "operationId": "patch_book_metadata", - "parameters": [ - { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Thumbnails" ], + "summary": "Generate thumbnails for books in a scope", + "description": "This queues a fan-out task that enqueues individual thumbnail generation tasks for each book.\n\n**Scope priority:**\n1. If `series_id` is provided, only books in that series\n2. If `library_id` is provided, only books in that library\n3. If neither is provided, all books in all libraries\n\n**Force behavior:**\n- `force: false` (default): Only generates thumbnails for books that don't have one\n- `force: true`: Regenerates all thumbnails, replacing existing ones\n\n# Permission Required\n- `tasks:write`", + "operationId": "generate_book_thumbnails", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchBookMetadataRequest" + "$ref": "#/components/schemas/GenerateBookThumbnailsRequest" } } }, @@ -2343,25 +2653,22 @@ }, "responses": { "200": { - "description": "Metadata updated successfully", + "description": "Thumbnail generation task queued", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookMetadataResponse" + "$ref": "#/components/schemas/CreateTaskResponse" } } } }, "403": { - "description": "Forbidden" - }, - "404": { - "description": "Book not found" + "description": "Permission denied" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -2369,14 +2676,13 @@ ] } }, - "/api/v1/books/{book_id}/pages/{page_number}": { + "/api/v1/books/{book_id}": { "get": { "tags": [ - "Pages" + "Books" ], - "summary": "Get page image from a book", - "description": "Extracts and serves the image for a specific page from CBZ/CBR/EPUB/PDF.\nFor PDF pages, supports HTTP conditional caching with ETag and Last-Modified\nheaders, returning 304 Not Modified when the client has a valid cached copy.", - "operationId": "get_page_image", + "summary": "Get book by ID", + "operationId": "get_book", "parameters": [ { "name": "book_id", @@ -2389,31 +2695,28 @@ } }, { - "name": "page_number", - "in": "path", - "description": "Page number (1-indexed)", - "required": true, + "name": "full", + "in": "query", + "description": "Return full data including metadata and locks.\nDefault is false for backward compatibility.", + "required": false, "schema": { - "type": "integer", - "format": "int32" + "type": "boolean" } } ], "responses": { "200": { - "description": "Page image", + "description": "Book details (returns FullBookResponse when full=true)", "content": { - "image/jpeg": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookDetailResponse" + } + } } }, - "304": { - "description": "Not modified (client cache is valid)" - }, - "403": { - "description": "Forbidden" - }, "404": { - "description": "Book or page not found" + "description": "Book not found" } }, "security": [ @@ -2424,19 +2727,19 @@ "api_key": [] } ] - } - }, - "/api/v1/books/{book_id}/progress": { - "get": { + }, + "patch": { "tags": [ - "Reading Progress" + "Books" ], - "summary": "Get reading progress for a book", - "operationId": "get_reading_progress", + "summary": "Update book core fields (title, number)", + "description": "Partially updates book_metadata fields. Only provided fields will be updated.\nAbsent fields are unchanged. Explicitly null fields will be cleared.\nWhen a field is set to a non-null value, it is automatically locked.", + "operationId": "patch_book", "parameters": [ { "name": "book_id", "in": "path", + "description": "Book ID", "required": true, "schema": { "type": "string", @@ -2444,46 +2747,57 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchBookRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Reading progress retrieved", + "description": "Book updated successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReadProgressResponse" + "$ref": "#/components/schemas/BookUpdateResponse" } } } }, - "401": { - "description": "Unauthorized" - }, "403": { "description": "Forbidden" }, "404": { - "description": "Progress not found" + "description": "Book not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] } ] - }, - "put": { + } + }, + "/api/v1/books/{book_id}/adjacent": { + "get": { "tags": [ - "Reading Progress" + "Books" ], - "summary": "Update reading progress for a book", - "operationId": "update_reading_progress", + "summary": "Get adjacent books in the same series", + "description": "Returns the previous and next books relative to the requested book,\nordered by book number within the series.", + "operationId": "get_adjacent_books", "parameters": [ { "name": "book_id", "in": "path", + "description": "Book ID", "required": true, "schema": { "type": "string", @@ -2491,56 +2805,44 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateProgressRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Progress updated successfully", + "description": "Adjacent books", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReadProgressResponse" + "$ref": "#/components/schemas/AdjacentBooksResponse" } } } }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, "404": { "description": "Book not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] } ] - }, - "delete": { + } + }, + "/api/v1/books/{book_id}/analyze": { + "post": { "tags": [ - "Reading Progress" + "Scans" ], - "summary": "Delete reading progress for a book", - "operationId": "delete_reading_progress", + "summary": "Trigger analysis of a single book (force reanalysis)", + "description": "# Permission Required\n- `books:write`\n\n# Behavior\nEnqueues an AnalyzeBook task with force=true.\nReturns immediately with a task_id to track progress.", + "operationId": "trigger_book_analysis", "parameters": [ { "name": "book_id", "in": "path", + "description": "Book ID", "required": true, "schema": { "type": "string", @@ -2549,14 +2851,21 @@ } ], "responses": { - "204": { - "description": "Progress deleted successfully" - }, - "401": { - "description": "Unauthorized" + "200": { + "description": "Analysis task enqueued successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTaskResponse" + } + } + } }, "403": { - "description": "Forbidden" + "description": "Permission denied" + }, + "404": { + "description": "Book not found" } }, "security": [ @@ -2569,17 +2878,19 @@ ] } }, - "/api/v1/books/{book_id}/read": { + "/api/v1/books/{book_id}/analyze-unanalyzed": { "post": { "tags": [ - "Reading Progress" + "Scans" ], - "summary": "Mark a book as read (completed)", - "operationId": "mark_book_as_read", + "summary": "Trigger analysis of a book if not already analyzed", + "description": "# Permission Required\n- `books:write`\n\n# Behavior\nEnqueues an AnalyzeBook task with force=false if the book has not been analyzed yet.\nReturns 400 if the book is already analyzed.\nReturns immediately with a task_id to track progress.", + "operationId": "trigger_book_unanalyzed_analysis", "parameters": [ { "name": "book_id", "in": "path", + "description": "Book ID", "required": true, "schema": { "type": "string", @@ -2589,20 +2900,20 @@ ], "responses": { "200": { - "description": "Book marked as read", + "description": "Analysis task enqueued successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReadProgressResponse" + "$ref": "#/components/schemas/CreateTaskResponse" } } } }, - "401": { - "description": "Unauthorized" + "400": { + "description": "Book is already analyzed" }, "403": { - "description": "Forbidden" + "description": "Permission denied" }, "404": { "description": "Book not found" @@ -2618,14 +2929,14 @@ ] } }, - "/api/v1/books/{book_id}/retry": { + "/api/v1/books/{book_id}/cover": { "post": { "tags": [ "Books" ], - "summary": "Retry failed operations for a specific book", - "description": "Enqueues appropriate tasks based on the error types present or specified.\nFor parser/metadata/page_extraction errors, enqueues an AnalyzeBook task.\nFor thumbnail errors, enqueues a GenerateThumbnail task.", - "operationId": "retry_book_errors", + "summary": "Upload a custom cover image for a book", + "description": "Accepts a multipart form with an image file. The image will be stored\nin the uploads directory and used as the book's cover/thumbnail.", + "operationId": "upload_book_cover", "parameters": [ { "name": "book_id", @@ -2640,37 +2951,15 @@ ], "requestBody": { "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RetryBookErrorsRequest" - }, - "example": { - "errorTypes": [ - "parser", - "thumbnail" - ] - } - } - }, - "required": true + "multipart/form-data": {} + } }, "responses": { "200": { - "description": "Retry tasks enqueued", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RetryErrorsResponse" - }, - "example": { - "message": "Enqueued 1 analysis task and 1 thumbnail task", - "tasksEnqueued": 2 - } - } - } + "description": "Cover uploaded successfully" }, "400": { - "description": "Book has no errors to retry" + "description": "Bad request - no image file provided or invalid image" }, "403": { "description": "Forbidden" @@ -2689,14 +2978,14 @@ ] } }, - "/api/v1/books/{book_id}/thumbnail": { + "/api/v1/books/{book_id}/file": { "get": { "tags": [ - "books" + "Books" ], - "summary": "Get thumbnail/cover image for a book", - "description": "Extracts the first page and resizes it to a thumbnail (max 400px width/height).\nSupports HTTP conditional caching with ETag and Last-Modified headers,\nreturning 304 Not Modified when the client has a valid cached copy.", - "operationId": "get_book_thumbnail", + "summary": "Download book file", + "description": "Streams the original book file (CBZ, CBR, EPUB, PDF) for download.\nUsed by OPDS clients for acquisition links.", + "operationId": "get_book_file", "parameters": [ { "name": "book_id", @@ -2711,14 +3000,11 @@ ], "responses": { "200": { - "description": "Thumbnail image", + "description": "Book file", "content": { - "image/jpeg": {} + "application/octet-stream": {} } }, - "304": { - "description": "Not modified (client cache is valid)" - }, "403": { "description": "Forbidden" }, @@ -2736,14 +3022,14 @@ ] } }, - "/api/v1/books/{book_id}/thumbnail/generate": { - "post": { + "/api/v1/books/{book_id}/metadata": { + "put": { "tags": [ - "Thumbnails" + "Books" ], - "summary": "Generate thumbnail for a single book", - "description": "Queues a task to generate (or regenerate) the thumbnail for a specific book.\n\n# Permission Required\n- `tasks:write`", - "operationId": "generate_book_thumbnail", + "summary": "Replace all book metadata (PUT)", + "description": "Completely replaces all metadata fields. Omitted or null fields will be cleared.\nIf no metadata record exists, one will be created.", + "operationId": "replace_book_metadata", "parameters": [ { "name": "book_id", @@ -2760,7 +3046,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ForceRequest" + "$ref": "#/components/schemas/ReplaceBookMetadataRequest" } } }, @@ -2768,17 +3054,17 @@ }, "responses": { "200": { - "description": "Thumbnail generation task queued", + "description": "Metadata replaced successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTaskResponse" + "$ref": "#/components/schemas/BookMetadataResponse" } } } }, "403": { - "description": "Permission denied" + "description": "Forbidden" }, "404": { "description": "Book not found" @@ -2786,25 +3072,25 @@ }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] } ] - } - }, - "/api/v1/books/{book_id}/unread": { - "post": { + }, + "patch": { "tags": [ - "Reading Progress" + "Books" ], - "summary": "Mark a book as unread (removes reading progress)", - "operationId": "mark_book_as_unread", + "summary": "Partially update book metadata (PATCH)", + "description": "Only provided fields will be updated. Absent fields are unchanged.\nExplicitly null fields will be cleared.\nIf no metadata record exists, one will be created with the provided fields.", + "operationId": "patch_book_metadata", "parameters": [ { "name": "book_id", "in": "path", + "description": "Book ID", "required": true, "schema": { "type": "string", @@ -2812,20 +3098,37 @@ } } ], - "responses": { - "204": { - "description": "Book marked as unread" + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchBookMetadataRequest" + } + } }, - "401": { - "description": "Unauthorized" + "required": true + }, + "responses": { + "200": { + "description": "Metadata updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookMetadataResponse" + } + } + } }, "403": { "description": "Forbidden" + }, + "404": { + "description": "Book not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -2833,68 +3136,103 @@ ] } }, - "/api/v1/duplicates": { + "/api/v1/books/{book_id}/metadata/locks": { "get": { "tags": [ - "Duplicates" + "Books" + ], + "summary": "Get book metadata lock states", + "description": "Returns which metadata fields are locked (protected from automatic updates).", + "operationId": "get_book_metadata_locks", + "parameters": [ + { + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "summary": "List all duplicate book groups", - "description": "# Permission Required\n- `books:read`", - "operationId": "list_duplicates", "responses": { "200": { - "description": "List of duplicate groups", + "description": "Lock states retrieved successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListDuplicatesResponse" + "$ref": "#/components/schemas/BookMetadataLocks" } } } }, "403": { - "description": "Permission denied" + "description": "Forbidden" + }, + "404": { + "description": "Book or metadata not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] } ] - } - }, - "/api/v1/duplicates/scan": { - "post": { + }, + "put": { "tags": [ - "Duplicates" + "Books" ], - "summary": "Trigger a manual duplicate detection scan", - "description": "# Permission Required\n- `books:write`", - "operationId": "trigger_duplicate_scan", + "summary": "Update book metadata lock states", + "description": "Updates which metadata fields are locked. Only provided fields will be updated.", + "operationId": "update_book_metadata_locks", + "parameters": [ + { + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateBookMetadataLocksRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Scan triggered", + "description": "Lock states updated successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TriggerDuplicateScanResponse" + "$ref": "#/components/schemas/BookMetadataLocks" } } } }, "403": { - "description": "Permission denied" + "description": "Forbidden" }, - "409": { - "description": "Scan already in progress" + "404": { + "description": "Book or metadata not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -2902,40 +3240,56 @@ ] } }, - "/api/v1/duplicates/{duplicate_id}": { - "delete": { + "/api/v1/books/{book_id}/pages/{page_number}": { + "get": { "tags": [ - "Duplicates" + "Pages" ], - "summary": "Delete a specific duplicate group (does not delete books, just the duplicate record)", - "description": "# Permission Required\n- `books:write`", - "operationId": "delete_duplicate_group", + "summary": "Get page image from a book", + "description": "Extracts and serves the image for a specific page from CBZ/CBR/EPUB/PDF.\nFor PDF pages, supports HTTP conditional caching with ETag and Last-Modified\nheaders, returning 304 Not Modified when the client has a valid cached copy.", + "operationId": "get_page_image", "parameters": [ { - "name": "duplicate_id", + "name": "book_id", "in": "path", - "description": "Duplicate group ID", + "description": "Book ID", "required": true, "schema": { "type": "string", "format": "uuid" } + }, + { + "name": "page_number", + "in": "path", + "description": "Page number (1-indexed)", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } } ], "responses": { - "204": { - "description": "Duplicate group deleted" + "200": { + "description": "Page image", + "content": { + "image/jpeg": {} + } + }, + "304": { + "description": "Not modified (client cache is valid)" }, "403": { - "description": "Permission denied" + "description": "Forbidden" }, "404": { - "description": "Duplicate group not found" + "description": "Book or page not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -2943,19 +3297,33 @@ ] } }, - "/api/v1/events/stream": { + "/api/v1/books/{book_id}/progress": { "get": { "tags": [ - "Events" + "Reading Progress" + ], + "summary": "Get reading progress for a book", + "operationId": "get_reading_progress", + "parameters": [ + { + "name": "book_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "summary": "Subscribe to real-time entity change events via SSE", - "description": "Clients can subscribe to this endpoint to receive real-time notifications\nabout entity changes (books, series, libraries) happening in the system.\n\n## Authentication\nRequires valid authentication with `LibrariesRead` permission.\n\n## Event Format\nEvents are sent as JSON-encoded `EntityChangeEvent` objects with the following structure:\n```json\n{\n \"type\": \"book_created\",\n \"book_id\": \"uuid\",\n \"series_id\": \"uuid\",\n \"library_id\": \"uuid\",\n \"timestamp\": \"2024-01-06T12:00:00Z\",\n \"user_id\": \"uuid\"\n}\n```\n\n## Keep-Alive\nA keep-alive message is sent every 15 seconds to prevent connection timeout.", - "operationId": "entity_events_stream", "responses": { "200": { - "description": "SSE stream of entity change events", + "description": "Reading progress retrieved", "content": { - "text/event-stream": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadProgressResponse" + } + } } }, "401": { @@ -2963,118 +3331,108 @@ }, "403": { "description": "Forbidden" + }, + "404": { + "description": "Progress not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - } - }, - "/api/v1/filesystem/browse": { - "get": { + }, + "put": { "tags": [ - "Filesystem" + "Reading Progress" ], - "summary": "Browse filesystem directories", - "description": "Returns a list of directories and files in the specified path", - "operationId": "browse_filesystem", + "summary": "Update reading progress for a book", + "operationId": "update_reading_progress", "parameters": [ { - "name": "path", - "in": "query", - "description": "Path to browse (defaults to user's home directory)", - "required": false, + "name": "book_id", + "in": "path", + "required": true, "schema": { - "type": [ - "string", - "null" - ] + "type": "string", + "format": "uuid" } } ], - "responses": { - "200": { - "description": "Directory contents", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BrowseResponse" - } + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProgressRequest" } } }, - "400": { - "description": "Invalid path", + "required": true + }, + "responses": { + "200": { + "description": "Progress updated successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ReadProgressResponse" } } } }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Forbidden", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } + "description": "Forbidden" + }, + "404": { + "description": "Book not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - } - }, - "/api/v1/filesystem/drives": { - "get": { + }, + "delete": { "tags": [ - "Filesystem" + "Reading Progress" ], - "summary": "Get system drives/volumes", - "description": "Returns a list of available drives or mount points on the system", - "operationId": "list_drives", - "responses": { - "200": { - "description": "Available drives", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileSystemEntry" - } - } - } + "summary": "Delete reading progress for a book", + "operationId": "delete_reading_progress", + "parameters": [ + { + "name": "book_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" } + } + ], + "responses": { + "204": { + "description": "Progress deleted successfully" + }, + "401": { + "description": "Unauthorized" }, "403": { - "description": "Forbidden", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } + "description": "Forbidden" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -3082,55 +3440,48 @@ ] } }, - "/api/v1/genres": { - "get": { + "/api/v1/books/{book_id}/read": { + "post": { "tags": [ - "Genres" + "Reading Progress" ], - "summary": "List all genres", - "operationId": "list_genres", + "summary": "Mark a book as read (completed)", + "operationId": "mark_book_as_read", "parameters": [ { - "name": "page", - "in": "query", - "description": "Page number (1-indexed, default 1)", - "required": false, + "name": "book_id", + "in": "path", + "required": true, "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, - { - "name": "pageSize", - "in": "query", - "description": "Number of items per page (default 50, max 500)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string", + "format": "uuid" } } ], "responses": { "200": { - "description": "List of all genres", + "description": "Book marked as read", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse_GenreDto" + "$ref": "#/components/schemas/ReadProgressResponse" } } } }, + "401": { + "description": "Unauthorized" + }, "403": { "description": "Forbidden" + }, + "404": { + "description": "Book not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -3138,26 +3489,65 @@ ] } }, - "/api/v1/genres/cleanup": { + "/api/v1/books/{book_id}/retry": { "post": { "tags": [ - "Genres" + "Books" ], - "summary": "Delete all unused genres (genres with no series linked)", - "operationId": "cleanup_genres", + "summary": "Retry failed operations for a specific book", + "description": "Enqueues appropriate tasks based on the error types present or specified.\nFor parser/metadata/page_extraction errors, enqueues an AnalyzeBook task.\nFor thumbnail errors, enqueues a GenerateThumbnail task.", + "operationId": "retry_book_errors", + "parameters": [ + { + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RetryBookErrorsRequest" + }, + "example": { + "errorTypes": [ + "parser", + "thumbnail" + ] + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Cleanup completed", + "description": "Retry tasks enqueued", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TaxonomyCleanupResponse" + "$ref": "#/components/schemas/RetryErrorsResponse" + }, + "example": { + "message": "Enqueued 1 analysis task and 1 thumbnail task", + "tasksEnqueued": 2 } } } }, + "400": { + "description": "Book has no errors to retry" + }, "403": { - "description": "Forbidden - admin only" + "description": "Forbidden" + }, + "404": { + "description": "Book not found" } }, "security": [ @@ -3170,18 +3560,19 @@ ] } }, - "/api/v1/genres/{genre_id}": { - "delete": { + "/api/v1/books/{book_id}/thumbnail": { + "get": { "tags": [ - "Genres" + "books" ], - "summary": "Delete a genre from the taxonomy (admin only)", - "operationId": "delete_genre", + "summary": "Get thumbnail/cover image for a book", + "description": "Extracts the first page and resizes it to a thumbnail (max 400px width/height).\nSupports HTTP conditional caching with ETag and Last-Modified headers,\nreturning 304 Not Modified when the client has a valid cached copy.", + "operationId": "get_book_thumbnail", "parameters": [ { - "name": "genre_id", + "name": "book_id", "in": "path", - "description": "Genre ID", + "description": "Book ID", "required": true, "schema": { "type": "string", @@ -3190,14 +3581,20 @@ } ], "responses": { - "204": { - "description": "Genre deleted" + "200": { + "description": "Thumbnail image", + "content": { + "image/jpeg": {} + } + }, + "304": { + "description": "Not modified (client cache is valid)" }, "403": { - "description": "Forbidden - admin only" + "description": "Forbidden" }, "404": { - "description": "Genre not found" + "description": "Book not found" } }, "security": [ @@ -3210,69 +3607,88 @@ ] } }, - "/api/v1/info": { - "get": { + "/api/v1/books/{book_id}/thumbnail/generate": { + "post": { "tags": [ - "Info" + "Thumbnails" ], - "summary": "Get application information", - "description": "Returns the application name and version.\nThis endpoint is public (no authentication required).", - "operationId": "get_app_info", + "summary": "Generate thumbnail for a single book", + "description": "Queues a task to generate (or regenerate) the thumbnail for a specific book.\n\n# Permission Required\n- `tasks:write`", + "operationId": "generate_book_thumbnail", + "parameters": [ + { + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForceRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Application info", + "description": "Thumbnail generation task queued", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AppInfoDto" + "$ref": "#/components/schemas/CreateTaskResponse" } } } + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Book not found" } - } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] } }, - "/api/v1/libraries": { - "get": { + "/api/v1/books/{book_id}/unread": { + "post": { "tags": [ - "Libraries" + "Reading Progress" ], - "summary": "List all libraries", - "operationId": "list_libraries", + "summary": "Mark a book as unread (removes reading progress)", + "operationId": "mark_book_as_unread", "parameters": [ { - "name": "page", - "in": "query", - "description": "Page number (1-indexed, default 1)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, - { - "name": "pageSize", - "in": "query", - "description": "Number of items per page (default 50, max 500)", - "required": false, + "name": "book_id", + "in": "path", + "required": true, "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string", + "format": "uuid" } } ], "responses": { - "200": { - "description": "List of libraries", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaginatedResponse_LibraryDto" - } - } - } + "204": { + "description": "Book marked as unread" + }, + "401": { + "description": "Unauthorized" }, "403": { "description": "Forbidden" @@ -3280,50 +3696,40 @@ }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - }, - "post": { + } + }, + "/api/v1/duplicates": { + "get": { "tags": [ - "Libraries" + "Duplicates" ], - "summary": "Create a new library", - "operationId": "create_library", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateLibraryRequest" - } - } - }, - "required": true - }, + "summary": "List all duplicate book groups", + "description": "# Permission Required\n- `books:read`", + "operationId": "list_duplicates", "responses": { - "201": { - "description": "Library created", + "200": { + "description": "List of duplicate groups", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LibraryDto" + "$ref": "#/components/schemas/ListDuplicatesResponse" } } } }, - "400": { - "description": "Invalid request" - }, "403": { - "description": "Forbidden" + "description": "Permission denied" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -3331,45 +3737,35 @@ ] } }, - "/api/v1/libraries/preview-scan": { + "/api/v1/duplicates/scan": { "post": { "tags": [ - "Libraries" + "Duplicates" ], - "summary": "Preview scan a path with a given strategy", - "description": "This endpoint allows users to preview how a scanning strategy would organize\nfiles without actually creating a library or importing anything. Useful for\ntesting strategy configurations before committing to them.", - "operationId": "preview_scan", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PreviewScanRequest" - } - } - }, - "required": true - }, + "summary": "Trigger a manual duplicate detection scan", + "description": "# Permission Required\n- `books:write`", + "operationId": "trigger_duplicate_scan", "responses": { "200": { - "description": "Preview scan results", + "description": "Scan triggered", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PreviewScanResponse" + "$ref": "#/components/schemas/TriggerDuplicateScanResponse" } } } }, - "400": { - "description": "Invalid request or path" - }, "403": { - "description": "Forbidden" + "description": "Permission denied" + }, + "409": { + "description": "Scan already in progress" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -3377,18 +3773,19 @@ ] } }, - "/api/v1/libraries/{library_id}": { - "get": { + "/api/v1/duplicates/{duplicate_id}": { + "delete": { "tags": [ - "Libraries" + "Duplicates" ], - "summary": "Get library by ID", - "operationId": "get_library", + "summary": "Delete a specific duplicate group (does not delete books, just the duplicate record)", + "description": "# Permission Required\n- `books:write`", + "operationId": "delete_duplicate_group", "parameters": [ { - "name": "library_id", + "name": "duplicate_id", "in": "path", - "description": "Library ID", + "description": "Duplicate group ID", "required": true, "schema": { "type": "string", @@ -3397,56 +3794,46 @@ } ], "responses": { - "200": { - "description": "Library details", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LibraryDto" - } - } - } + "204": { + "description": "Duplicate group deleted" + }, + "403": { + "description": "Permission denied" }, "404": { - "description": "Library not found" + "description": "Duplicate group not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - }, - "delete": { + } + }, + "/api/v1/events/stream": { + "get": { "tags": [ - "Libraries" - ], - "summary": "Delete a library", - "operationId": "delete_library", - "parameters": [ - { - "name": "library_id", - "in": "path", - "description": "Library ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Events" ], + "summary": "Subscribe to real-time entity change events via SSE", + "description": "Clients can subscribe to this endpoint to receive real-time notifications\nabout entity changes (books, series, libraries) happening in the system.\n\n## Authentication\nRequires valid authentication with `LibrariesRead` permission.\n\n## Event Format\nEvents are sent as JSON-encoded `EntityChangeEvent` objects with the following structure:\n```json\n{\n \"type\": \"book_created\",\n \"book_id\": \"uuid\",\n \"series_id\": \"uuid\",\n \"library_id\": \"uuid\",\n \"timestamp\": \"2024-01-06T12:00:00Z\",\n \"user_id\": \"uuid\"\n}\n```\n\n## Keep-Alive\nA keep-alive message is sent every 15 seconds to prevent connection timeout.", + "operationId": "entity_events_stream", "responses": { - "204": { - "description": "Library deleted" + "200": { + "description": "SSE stream of entity change events", + "content": { + "text/event-stream": {} + } + }, + "401": { + "description": "Unauthorized" }, "403": { "description": "Forbidden" - }, - "404": { - "description": "Library not found" } }, "security": [ @@ -3457,51 +3844,60 @@ "api_key": [] } ] - }, - "patch": { + } + }, + "/api/v1/filesystem/browse": { + "get": { "tags": [ - "Libraries" + "Filesystem" ], - "summary": "Update a library (partial update)", - "operationId": "update_library", + "summary": "Browse filesystem directories", + "description": "Returns a list of directories and files in the specified path", + "operationId": "browse_filesystem", "parameters": [ { - "name": "library_id", - "in": "path", - "description": "Library ID", - "required": true, + "name": "path", + "in": "query", + "description": "Path to browse (defaults to user's home directory)", + "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": [ + "string", + "null" + ] } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateLibraryRequest" + "responses": { + "200": { + "description": "Directory contents", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrowseResponse" + } } } }, - "required": true - }, - "responses": { - "200": { - "description": "Library updated", + "400": { + "description": "Invalid path", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LibraryDto" + "$ref": "#/components/schemas/ErrorResponse" } } } }, "403": { - "description": "Forbidden" - }, - "404": { - "description": "Library not found" + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } }, "security": [ @@ -3514,47 +3910,42 @@ ] } }, - "/api/v1/libraries/{library_id}/analyze": { - "post": { + "/api/v1/filesystem/drives": { + "get": { "tags": [ - "Scans" - ], - "summary": "Trigger forced analysis of all books in a library", - "description": "# Permission Required\n- `libraries:write`\n\n# Behavior\nEnqueues AnalyzeBook tasks (with force=true) for ALL books in the library.\nThis forces re-analysis even for books that have been analyzed before.\nReturns immediately with a task_id to track progress.", - "operationId": "trigger_library_analysis", - "parameters": [ - { - "name": "library_id", - "in": "path", - "description": "Library ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Filesystem" ], + "summary": "Get system drives/volumes", + "description": "Returns a list of available drives or mount points on the system", + "operationId": "list_drives", "responses": { "200": { - "description": "Analysis tasks enqueued successfully", + "description": "Available drives", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTaskResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/FileSystemEntry" + } } } } }, "403": { - "description": "Permission denied" - }, - "404": { - "description": "Library not found" + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -3562,47 +3953,55 @@ ] } }, - "/api/v1/libraries/{library_id}/analyze-unanalyzed": { - "post": { + "/api/v1/genres": { + "get": { "tags": [ - "Scans" + "Genres" ], - "summary": "Trigger analysis of unanalyzed books in a library", - "description": "# Permission Required\n- `libraries:write`\n\n# Behavior\nEnqueues AnalyzeBook tasks (with force=false) for books that have not been analyzed yet.\nThis is useful for recovering from failures or analyzing newly discovered books.\nReturns immediately with a task_id to track progress.", - "operationId": "trigger_library_unanalyzed_analysis", + "summary": "List all genres", + "operationId": "list_genres", "parameters": [ { - "name": "library_id", - "in": "path", - "description": "Library ID", - "required": true, + "name": "page", + "in": "query", + "description": "Page number (1-indexed, default 1)", + "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "pageSize", + "in": "query", + "description": "Number of items per page (default 50, max 500)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 } } ], "responses": { "200": { - "description": "Analysis tasks enqueued successfully", + "description": "List of all genres", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTaskResponse" + "$ref": "#/components/schemas/PaginatedResponse_GenreDto" } } } }, "403": { - "description": "Permission denied" - }, - "404": { - "description": "Library not found" + "description": "Forbidden" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -3610,60 +4009,26 @@ ] } }, - "/api/v1/libraries/{library_id}/books": { - "get": { + "/api/v1/genres/cleanup": { + "post": { "tags": [ - "Books" - ], - "summary": "List books in a specific library", - "operationId": "list_library_books", - "parameters": [ - { - "name": "library_id", - "in": "path", - "description": "Library ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "page", - "in": "path", - "description": "Page number (1-indexed, minimum 1)", - "required": true, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, - { - "name": "pageSize", - "in": "path", - "description": "Number of items per page (max 100, default 50)", - "required": true, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - } + "Genres" ], + "summary": "Delete all unused genres (genres with no series linked)", + "operationId": "cleanup_genres", "responses": { "200": { - "description": "Paginated list of books in library", + "description": "Cleanup completed", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse" + "$ref": "#/components/schemas/TaxonomyCleanupResponse" } } } }, "403": { - "description": "Forbidden" + "description": "Forbidden - admin only" } }, "security": [ @@ -3676,29 +4041,81 @@ ] } }, - "/api/v1/libraries/{library_id}/books/in-progress": { - "get": { + "/api/v1/genres/{genre_id}": { + "delete": { "tags": [ - "Books" + "Genres" ], - "summary": "List books with reading progress in a specific library (in-progress books)", - "operationId": "list_library_in_progress_books", + "summary": "Delete a genre from the taxonomy (admin only)", + "operationId": "delete_genre", "parameters": [ { - "name": "library_id", + "name": "genre_id", "in": "path", - "description": "Library ID", + "description": "Genre ID", "required": true, "schema": { "type": "string", "format": "uuid" } + } + ], + "responses": { + "204": { + "description": "Genre deleted" + }, + "403": { + "description": "Forbidden - admin only" + }, + "404": { + "description": "Genre not found" + } + }, + "security": [ + { + "jwt_bearer": [] }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/info": { + "get": { + "tags": [ + "Info" + ], + "summary": "Get application information", + "description": "Returns the application name and version.\nThis endpoint is public (no authentication required).", + "operationId": "get_app_info", + "responses": { + "200": { + "description": "Application info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppInfoDto" + } + } + } + } + } + } + }, + "/api/v1/libraries": { + "get": { + "tags": [ + "Libraries" + ], + "summary": "List all libraries", + "operationId": "list_libraries", + "parameters": [ { "name": "page", - "in": "path", - "description": "Page number (1-indexed, minimum 1)", - "required": true, + "in": "query", + "description": "Page number (1-indexed, default 1)", + "required": false, "schema": { "type": "integer", "format": "int64", @@ -3707,9 +4124,9 @@ }, { "name": "pageSize", - "in": "path", - "description": "Number of items per page (max 100, default 50)", - "required": true, + "in": "query", + "description": "Number of items per page (default 50, max 500)", + "required": false, "schema": { "type": "integer", "format": "int64", @@ -3719,11 +4136,11 @@ ], "responses": { "200": { - "description": "Paginated list of in-progress books in library", + "description": "List of libraries", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse" + "$ref": "#/components/schemas/PaginatedResponse_LibraryDto" } } } @@ -3740,60 +4157,37 @@ "api_key": [] } ] - } - }, - "/api/v1/libraries/{library_id}/books/on-deck": { - "get": { + }, + "post": { "tags": [ - "Books" + "Libraries" ], - "summary": "List on-deck books in a specific library", - "operationId": "list_library_on_deck_books", - "parameters": [ - { - "name": "library_id", - "in": "path", - "description": "Library ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "page", - "in": "path", - "description": "Page number (1-indexed, minimum 1)", - "required": true, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "summary": "Create a new library", + "operationId": "create_library", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateLibraryRequest" + } } }, - { - "name": "pageSize", - "in": "path", - "description": "Number of items per page (max 100, default 50)", - "required": true, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - } - ], + "required": true + }, "responses": { - "200": { - "description": "Paginated list of on-deck books in library", + "201": { + "description": "Library created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse" + "$ref": "#/components/schemas/LibraryDto" } } } }, + "400": { + "description": "Invalid request" + }, "403": { "description": "Forbidden" } @@ -3808,58 +4202,38 @@ ] } }, - "/api/v1/libraries/{library_id}/books/recently-added": { - "get": { + "/api/v1/libraries/preview-scan": { + "post": { "tags": [ - "Books" + "Libraries" ], - "summary": "List recently added books in a specific library", - "operationId": "list_library_recently_added_books", - "parameters": [ - { - "name": "library_id", - "in": "path", - "description": "Library ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "page", - "in": "path", - "description": "Page number (1-indexed, minimum 1)", - "required": true, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "summary": "Preview scan a path with a given strategy", + "description": "This endpoint allows users to preview how a scanning strategy would organize\nfiles without actually creating a library or importing anything. Useful for\ntesting strategy configurations before committing to them.", + "operationId": "preview_scan", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewScanRequest" + } } }, - { - "name": "pageSize", - "in": "path", - "description": "Number of items per page (max 100, default 50)", - "required": true, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - } - ], + "required": true + }, "responses": { "200": { - "description": "Paginated list of recently added books in library", + "description": "Preview scan results", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse" + "$ref": "#/components/schemas/PreviewScanResponse" } } } }, + "400": { + "description": "Invalid request or path" + }, "403": { "description": "Forbidden" } @@ -3874,16 +4248,17 @@ ] } }, - "/api/v1/libraries/{library_id}/books/recently-read": { - "get": { + "/api/v1/libraries/{id}/metadata/auto-match/task": { + "post": { "tags": [ - "Books" + "Plugin Actions" ], - "summary": "List recently read books in a specific library", - "operationId": "list_library_recently_read_books", + "summary": "Enqueue plugin auto-match tasks for all series in a library", + "description": "Creates background tasks to auto-match metadata for all series in a library using\nthe specified plugin. Each series gets its own task that runs asynchronously.", + "operationId": "enqueue_library_auto_match_tasks", "parameters": [ { - "name": "library_id", + "name": "id", "in": "path", "description": "Library ID", "required": true, @@ -3891,40 +4266,45 @@ "type": "string", "format": "uuid" } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnqueueLibraryAutoMatchRequest" + } + } }, - { - "name": "limit", - "in": "query", - "description": "Maximum number of books to return (default: 50)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - } - ], + "required": true + }, "responses": { "200": { - "description": "List of recently read books in library", + "description": "Tasks enqueued", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BookDto" - } + "$ref": "#/components/schemas/EnqueueAutoMatchResponse" } } } }, + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Forbidden" + "description": "No permission to edit series" + }, + "404": { + "description": "Library or plugin not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -3932,13 +4312,13 @@ ] } }, - "/api/v1/libraries/{library_id}/books/with-errors": { + "/api/v1/libraries/{library_id}": { "get": { "tags": [ - "Books" + "Libraries" ], - "summary": "List books with analysis errors in a specific library", - "operationId": "list_library_books_with_errors", + "summary": "Get library by ID", + "operationId": "get_library", "parameters": [ { "name": "library_id", @@ -3949,69 +4329,21 @@ "type": "string", "format": "uuid" } - }, - { - "name": "libraryId", - "in": "query", - "description": "Optional library filter", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "seriesId", - "in": "query", - "description": "Optional series filter", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "page", - "in": "query", - "description": "Page number (1-indexed, minimum 1)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, - { - "name": "pageSize", - "in": "query", - "description": "Number of items per page (max 100, default 50)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } } ], "responses": { "200": { - "description": "Paginated list of books with analysis errors in library", + "description": "Library details", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse" + "$ref": "#/components/schemas/LibraryDto" } } } }, - "403": { - "description": "Forbidden" + "404": { + "description": "Library not found" } }, "security": [ @@ -4022,15 +4354,13 @@ "api_key": [] } ] - } - }, - "/api/v1/libraries/{library_id}/purge-deleted": { + }, "delete": { "tags": [ "Libraries" ], - "summary": "Purge deleted books from a library", - "operationId": "purge_deleted_books", + "summary": "Delete a library", + "operationId": "delete_library", "parameters": [ { "name": "library_id", @@ -4044,17 +4374,8 @@ } ], "responses": { - "200": { - "description": "Number of books purged", - "content": { - "text/plain": { - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - } - } + "204": { + "description": "Library deleted" }, "403": { "description": "Forbidden" @@ -4071,16 +4392,13 @@ "api_key": [] } ] - } - }, - "/api/v1/libraries/{library_id}/scan": { - "post": { + }, + "patch": { "tags": [ - "Scans" + "Libraries" ], - "summary": "Trigger a library scan", - "description": "# Permission Required\n- `libraries:write`", - "operationId": "trigger_scan", + "summary": "Update a library (partial update)", + "operationId": "update_library", "parameters": [ { "name": "library_id", @@ -4091,44 +4409,39 @@ "type": "string", "format": "uuid" } - }, - { - "name": "mode", - "in": "query", - "description": "Scan mode: 'normal' or 'deep' (default: 'normal')", - "required": false, - "schema": { - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateLibraryRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Scan started successfully", + "description": "Library updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ScanStatusDto" + "$ref": "#/components/schemas/LibraryDto" } } } }, - "400": { - "description": "Invalid scan mode" - }, "403": { - "description": "Permission denied" + "description": "Forbidden" }, "404": { "description": "Library not found" - }, - "409": { - "description": "Scan already in progress" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -4136,14 +4449,14 @@ ] } }, - "/api/v1/libraries/{library_id}/scan-status": { - "get": { + "/api/v1/libraries/{library_id}/analyze": { + "post": { "tags": [ "Scans" ], - "summary": "Get scan status for a library", - "description": "# Permission Required\n- `libraries:read`", - "operationId": "get_scan_status", + "summary": "Trigger forced analysis of all books in a library", + "description": "# Permission Required\n- `libraries:write`\n\n# Behavior\nEnqueues AnalyzeBook tasks (with force=true) for ALL books in the library.\nThis forces re-analysis even for books that have been analyzed before.\nReturns immediately with a task_id to track progress.", + "operationId": "trigger_library_analysis", "parameters": [ { "name": "library_id", @@ -4158,11 +4471,11 @@ ], "responses": { "200": { - "description": "Scan status retrieved", + "description": "Analysis tasks enqueued successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ScanStatusDto" + "$ref": "#/components/schemas/CreateTaskResponse" } } } @@ -4171,7 +4484,7 @@ "description": "Permission denied" }, "404": { - "description": "No scan found for this library" + "description": "Library not found" } }, "security": [ @@ -4184,14 +4497,14 @@ ] } }, - "/api/v1/libraries/{library_id}/scan/cancel": { + "/api/v1/libraries/{library_id}/analyze-unanalyzed": { "post": { "tags": [ "Scans" ], - "summary": "Cancel a running scan", - "description": "# Permission Required\n- `libraries:write`", - "operationId": "cancel_scan", + "summary": "Trigger analysis of unanalyzed books in a library", + "description": "# Permission Required\n- `libraries:write`\n\n# Behavior\nEnqueues AnalyzeBook tasks (with force=false) for books that have not been analyzed yet.\nThis is useful for recovering from failures or analyzing newly discovered books.\nReturns immediately with a task_id to track progress.", + "operationId": "trigger_library_unanalyzed_analysis", "parameters": [ { "name": "library_id", @@ -4205,14 +4518,21 @@ } ], "responses": { - "204": { - "description": "Scan cancelled successfully" + "200": { + "description": "Analysis tasks enqueued successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTaskResponse" + } + } + } }, "403": { "description": "Permission denied" }, "404": { - "description": "No active scan found" + "description": "Library not found" } }, "security": [ @@ -4225,13 +4545,13 @@ ] } }, - "/api/v1/libraries/{library_id}/series": { + "/api/v1/libraries/{library_id}/books": { "get": { "tags": [ - "Series" + "Books" ], - "summary": "List series in a specific library with pagination", - "operationId": "list_library_series", + "summary": "List books in a specific library", + "operationId": "list_library_books", "parameters": [ { "name": "library_id", @@ -4245,9 +4565,9 @@ }, { "name": "page", - "in": "query", - "description": "Page number (1-indexed, default 1)", - "required": false, + "in": "path", + "description": "Page number (1-indexed, minimum 1)", + "required": true, "schema": { "type": "integer", "format": "int64", @@ -4256,77 +4576,85 @@ }, { "name": "pageSize", - "in": "query", + "in": "path", "description": "Number of items per page (max 100, default 50)", - "required": false, + "required": true, "schema": { "type": "integer", "format": "int64", "minimum": 0 } - }, - { - "name": "sort", - "in": "query", - "description": "Sort parameter (format: \"field,direction\" e.g. \"name,asc\")", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] + } + ], + "responses": { + "200": { + "description": "Paginated list of books in library", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse" + } + } } }, + "403": { + "description": "Forbidden" + } + }, + "security": [ { - "name": "genres", - "in": "query", - "description": "Filter by genres (comma-separated, AND logic - series must have ALL specified genres)", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } + "jwt_bearer": [] }, { - "name": "tags", - "in": "query", - "description": "Filter by tags (comma-separated, AND logic - series must have ALL specified tags)", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] + "api_key": [] + } + ] + } + }, + "/api/v1/libraries/{library_id}/books/in-progress": { + "get": { + "tags": [ + "Books" + ], + "summary": "List books with reading progress in a specific library (in-progress books)", + "operationId": "list_library_in_progress_books", + "parameters": [ + { + "name": "library_id", + "in": "path", + "description": "Library ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" } }, { - "name": "libraryId", - "in": "query", - "description": "Filter by library ID", - "required": false, + "name": "page", + "in": "path", + "description": "Page number (1-indexed, minimum 1)", + "required": true, "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" + "type": "integer", + "format": "int64", + "minimum": 0 } }, { - "name": "full", - "in": "query", - "description": "Return full series data including metadata, locks, genres, tags, alternate titles,\nexternal ratings, and external links. Default is false for backward compatibility.", - "required": false, + "name": "pageSize", + "in": "path", + "description": "Number of items per page (max 100, default 50)", + "required": true, "schema": { - "type": "boolean" + "type": "integer", + "format": "int64", + "minimum": 0 } } ], "responses": { "200": { - "description": "Paginated list of series in library (returns FullSeriesListResponse when full=true)", + "description": "Paginated list of in-progress books in library", "content": { "application/json": { "schema": { @@ -4349,13 +4677,13 @@ ] } }, - "/api/v1/libraries/{library_id}/series/in-progress": { + "/api/v1/libraries/{library_id}/books/on-deck": { "get": { "tags": [ - "Series" + "Books" ], - "summary": "List in-progress series in a specific library", - "operationId": "list_library_in_progress_series", + "summary": "List on-deck books in a specific library", + "operationId": "list_library_on_deck_books", "parameters": [ { "name": "library_id", @@ -4368,25 +4696,35 @@ } }, { - "name": "full", - "in": "query", - "description": "Return full series data including metadata, locks, genres, tags, etc.", - "required": false, + "name": "page", + "in": "path", + "description": "Page number (1-indexed, minimum 1)", + "required": true, "schema": { - "type": "boolean" + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "pageSize", + "in": "path", + "description": "Number of items per page (max 100, default 50)", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 } } ], "responses": { "200": { - "description": "List of in-progress series in library (returns Vec when full=true)", + "description": "Paginated list of on-deck books in library", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SeriesDto" - } + "$ref": "#/components/schemas/PaginatedResponse" } } } @@ -4405,13 +4743,13 @@ ] } }, - "/api/v1/libraries/{library_id}/series/recently-added": { + "/api/v1/libraries/{library_id}/books/recently-added": { "get": { "tags": [ - "Series" + "Books" ], - "summary": "List recently added series in a specific library", - "operationId": "list_library_recently_added_series", + "summary": "List recently added books in a specific library", + "operationId": "list_library_recently_added_books", "parameters": [ { "name": "library_id", @@ -4424,10 +4762,10 @@ } }, { - "name": "limit", - "in": "query", - "description": "Maximum number of series to return (default: 50)", - "required": false, + "name": "page", + "in": "path", + "description": "Page number (1-indexed, minimum 1)", + "required": true, "schema": { "type": "integer", "format": "int64", @@ -4435,38 +4773,24 @@ } }, { - "name": "libraryId", - "in": "query", - "description": "Filter by library ID (optional)", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "full", - "in": "query", - "description": "Return full series data including metadata, locks, genres, tags, etc.", - "required": false, + "name": "pageSize", + "in": "path", + "description": "Number of items per page (max 100, default 50)", + "required": true, "schema": { - "type": "boolean" + "type": "integer", + "format": "int64", + "minimum": 0 } } ], "responses": { "200": { - "description": "List of recently added series in library (returns Vec when full=true)", + "description": "Paginated list of recently added books in library", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SeriesDto" - } + "$ref": "#/components/schemas/PaginatedResponse" } } } @@ -4485,13 +4809,13 @@ ] } }, - "/api/v1/libraries/{library_id}/series/recently-updated": { + "/api/v1/libraries/{library_id}/books/recently-read": { "get": { "tags": [ - "Series" + "Books" ], - "summary": "List recently updated series in a specific library", - "operationId": "list_library_recently_updated_series", + "summary": "List recently read books in a specific library", + "operationId": "list_library_recently_read_books", "parameters": [ { "name": "library_id", @@ -4506,46 +4830,24 @@ { "name": "limit", "in": "query", - "description": "Maximum number of series to return (default: 50)", + "description": "Maximum number of books to return (default: 50)", "required": false, "schema": { "type": "integer", "format": "int64", "minimum": 0 } - }, - { - "name": "libraryId", - "in": "query", - "description": "Filter by library ID (optional)", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "full", - "in": "query", - "description": "Return full series data including metadata, locks, genres, tags, etc.", - "required": false, - "schema": { - "type": "boolean" - } } ], "responses": { "200": { - "description": "List of recently updated series in library (returns Vec when full=true)", + "description": "List of recently read books in library", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/SeriesDto" + "$ref": "#/components/schemas/BookDto" } } } @@ -4565,14 +4867,14 @@ ] } }, - "/api/v1/libraries/{library_id}/thumbnails/generate": { + "/api/v1/libraries/{library_id}/books/thumbnails/generate": { "post": { "tags": [ "Thumbnails" ], "summary": "Generate thumbnails for all books in a library", "description": "Queues a fan-out task that enqueues individual thumbnail generation tasks for each book in the library.\n\n# Permission Required\n- `tasks:write`", - "operationId": "generate_library_thumbnails", + "operationId": "generate_library_book_thumbnails", "parameters": [ { "name": "library_id", @@ -4623,32 +4925,48 @@ ] } }, - "/api/v1/metrics/inventory": { - "get": { + "/api/v1/libraries/{library_id}/purge-deleted": { + "delete": { "tags": [ - "Metrics" + "Libraries" + ], + "summary": "Purge deleted books from a library", + "operationId": "purge_deleted_books", + "parameters": [ + { + "name": "library_id", + "in": "path", + "description": "Library ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "summary": "Get inventory metrics (library/book counts)", - "description": "Returns counts and sizes for libraries, series, and books in the system.\nThis endpoint provides an inventory overview of your digital library.\n\n# Permission Required\n- `libraries:read` or admin status", - "operationId": "get_inventory_metrics", "responses": { "200": { - "description": "Inventory metrics retrieved successfully", + "description": "Number of books purged", "content": { - "application/json": { + "text/plain": { "schema": { - "$ref": "#/components/schemas/MetricsDto" + "type": "integer", + "format": "int64", + "minimum": 0 } } } }, "403": { - "description": "Permission denied" + "description": "Forbidden" + }, + "404": { + "description": "Library not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -4656,100 +4974,57 @@ ] } }, - "/api/v1/metrics/tasks": { - "get": { + "/api/v1/libraries/{library_id}/scan": { + "post": { "tags": [ - "Metrics" + "Scans" + ], + "summary": "Trigger a library scan", + "description": "# Permission Required\n- `libraries:write`", + "operationId": "trigger_scan", + "parameters": [ + { + "name": "library_id", + "in": "path", + "description": "Library ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "mode", + "in": "query", + "description": "Scan mode: 'normal' or 'deep' (default: 'normal')", + "required": false, + "schema": { + "type": "string" + } + } ], - "summary": "Get current task metrics", - "description": "Returns real-time task performance statistics including:\n- Summary metrics across all task types\n- Per-task-type breakdown with timing, throughput, and error rates\n- Queue health metrics (pending, processing, stale counts)\n\n# Permission Required\n- `libraries:read` or admin status", - "operationId": "get_task_metrics", "responses": { "200": { - "description": "Task metrics retrieved successfully", + "description": "Scan started successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TaskMetricsResponse" + "$ref": "#/components/schemas/ScanStatusDto" } } } }, + "400": { + "description": "Invalid scan mode" + }, "403": { "description": "Permission denied" }, - "503": { - "description": "Task metrics service not available" - } - }, - "security": [ - { - "bearer_auth": [] - }, - { - "api_key": [] - } - ] - }, - "delete": { - "tags": [ - "Metrics" - ], - "summary": "Delete all task metrics", - "description": "Permanently deletes all task metric records from the database\nand clears in-memory aggregates. This action cannot be undone.\n\n# Permission Required\n- Admin status required", - "operationId": "nuke_task_metrics", - "responses": { - "200": { - "description": "All metrics deleted successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MetricsNukeResponse" - } - } - } - }, - "403": { - "description": "Permission denied - admin required" - }, - "503": { - "description": "Task metrics service not available" - } - }, - "security": [ - { - "bearer_auth": [] - }, - { - "api_key": [] - } - ] - } - }, - "/api/v1/metrics/tasks/cleanup": { - "post": { - "tags": [ - "Metrics" - ], - "summary": "Trigger manual metrics cleanup", - "description": "Deletes metric records older than the configured retention period.\nThis operation normally runs automatically daily.\n\n# Permission Required\n- Admin status required", - "operationId": "trigger_metrics_cleanup", - "responses": { - "200": { - "description": "Cleanup completed successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MetricsCleanupResponse" - } - } - } - }, - "403": { - "description": "Permission denied - admin required" + "404": { + "description": "Library not found" }, - "503": { - "description": "Task metrics service not available" + "409": { + "description": "Scan already in progress" } }, "security": [ @@ -4762,63 +5037,33 @@ ] } }, - "/api/v1/metrics/tasks/history": { + "/api/v1/libraries/{library_id}/scan-status": { "get": { "tags": [ - "Metrics" + "Scans" ], - "summary": "Get task metrics history", - "description": "Returns historical task performance data for trend analysis.\nData is aggregated by hour or day depending on the granularity parameter.\n\n# Permission Required\n- `libraries:read` or admin status", - "operationId": "get_task_metrics_history", + "summary": "Get scan status for a library", + "description": "# Permission Required\n- `libraries:read`", + "operationId": "get_scan_status", "parameters": [ { - "name": "days", - "in": "query", - "description": "Number of days to retrieve (default: 7)", - "required": false, - "schema": { - "type": [ - "integer", - "null" - ], - "format": "int32" - }, - "example": 7 - }, - { - "name": "taskType", - "in": "query", - "description": "Filter by task type", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - }, - "example": "scan_library" - }, - { - "name": "granularity", - "in": "query", - "description": "Granularity: \"hour\" or \"day\" (default: hour)", - "required": false, + "name": "library_id", + "in": "path", + "description": "Library ID", + "required": true, "schema": { - "type": [ - "string", - "null" - ] - }, - "example": "hour" + "type": "string", + "format": "uuid" + } } ], "responses": { "200": { - "description": "Task metrics history retrieved successfully", + "description": "Scan status retrieved", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TaskMetricsHistoryResponse" + "$ref": "#/components/schemas/ScanStatusDto" } } } @@ -4826,43 +5071,8 @@ "403": { "description": "Permission denied" }, - "503": { - "description": "Task metrics service not available" - } - }, - "security": [ - { - "bearer_auth": [] - }, - { - "api_key": [] - } - ] - } - }, - "/api/v1/progress": { - "get": { - "tags": [ - "Reading Progress" - ], - "summary": "Get all reading progress for the authenticated user", - "operationId": "get_user_progress", - "responses": { - "200": { - "description": "User reading progress retrieved", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReadProgressListResponse" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" + "404": { + "description": "No scan found for this library" } }, "security": [ @@ -4875,56 +5085,35 @@ ] } }, - "/api/v1/scans/active": { - "get": { + "/api/v1/libraries/{library_id}/scan/cancel": { + "post": { "tags": [ "Scans" ], - "summary": "List all active scans", - "description": "# Permission Required\n- `libraries:read`", - "operationId": "list_active_scans", - "responses": { - "200": { - "description": "List of active scans", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ScanStatusDto" - } - } - } - } - }, - "403": { - "description": "Permission denied" - } - }, - "security": [ - { - "bearer_auth": [] - }, + "summary": "Cancel a running scan", + "description": "# Permission Required\n- `libraries:write`", + "operationId": "cancel_scan", + "parameters": [ { - "api_key": [] + "name": "library_id", + "in": "path", + "description": "Library ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } } - ] - } - }, - "/api/v1/scans/stream": { - "get": { - "tags": [ - "Scans" ], - "summary": "Stream scan progress updates via Server-Sent Events", - "description": "# Permission Required\n- `libraries:read`\n\n**DEPRECATED**: This endpoint is replaced by `/api/v1/tasks/stream` which provides\nreal-time updates for all task types including scans. This endpoint now filters\nthe task stream to only show scan_library tasks for backwards compatibility.", - "operationId": "scan_progress_stream", "responses": { - "200": { - "description": "SSE stream of scan progress updates" + "204": { + "description": "Scan cancelled successfully" }, "403": { "description": "Permission denied" + }, + "404": { + "description": "No active scan found" } }, "security": [ @@ -4937,14 +5126,24 @@ ] } }, - "/api/v1/series": { + "/api/v1/libraries/{library_id}/series": { "get": { "tags": [ "Series" ], - "summary": "List series with optional library filter and pagination", - "operationId": "list_series", + "summary": "List series in a specific library with pagination", + "operationId": "list_library_series", "parameters": [ + { + "name": "library_id", + "in": "path", + "description": "Library ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, { "name": "page", "in": "query", @@ -5028,7 +5227,7 @@ ], "responses": { "200": { - "description": "Paginated list of series (returns FullSeriesListResponse when full=true)", + "description": "Paginated list of series in library (returns FullSeriesListResponse when full=true)", "content": { "application/json": { "schema": { @@ -5051,24 +5250,21 @@ ] } }, - "/api/v1/series/in-progress": { + "/api/v1/libraries/{library_id}/series/in-progress": { "get": { "tags": [ "Series" ], - "summary": "List series with in-progress books (series that have at least one book with reading progress that is not completed)", - "operationId": "list_in_progress_series", + "summary": "List in-progress series in a specific library", + "operationId": "list_library_in_progress_series", "parameters": [ { - "name": "libraryId", - "in": "query", - "description": "Filter by library ID (optional)", - "required": false, + "name": "library_id", + "in": "path", + "description": "Library ID", + "required": true, "schema": { - "type": [ - "string", - "null" - ], + "type": "string", "format": "uuid" } }, @@ -5084,7 +5280,7 @@ ], "responses": { "200": { - "description": "List of in-progress series (returns Vec when full=true)", + "description": "List of in-progress series in library (returns Vec when full=true)", "content": { "application/json": { "schema": { @@ -5110,79 +5306,68 @@ ] } }, - "/api/v1/series/list": { - "post": { + "/api/v1/libraries/{library_id}/series/recently-added": { + "get": { "tags": [ "Series" ], - "summary": "List series with advanced filtering", - "description": "Supports complex filter conditions including nested AllOf/AnyOf logic,\ngenre/tag filtering with include/exclude, and more.\n\nPagination parameters (page, pageSize, sort) are passed as query parameters.\nFilter conditions are passed in the request body.", - "operationId": "list_series_filtered", + "summary": "List recently added series in a specific library", + "operationId": "list_library_recently_added_series", "parameters": [ { - "name": "page", - "in": "query", - "description": "Page number (1-indexed, minimum 1)", - "required": false, + "name": "library_id", + "in": "path", + "description": "Library ID", + "required": true, "schema": { - "type": "integer", - "format": "int64", - "default": 1, - "minimum": 1 + "type": "string", + "format": "uuid" } }, { - "name": "pageSize", + "name": "limit", "in": "query", - "description": "Number of items per page (max 500, default 50)", + "description": "Maximum number of series to return (default: 50)", "required": false, "schema": { "type": "integer", "format": "int64", - "default": 50, - "maximum": 500, - "minimum": 1 + "minimum": 0 } }, { - "name": "sort", + "name": "libraryId", "in": "query", - "description": "Sort field and direction (e.g., \"name,asc\" or \"createdAt,desc\")", + "description": "Filter by library ID (optional)", "required": false, "schema": { "type": [ "string", "null" - ] + ], + "format": "uuid" } }, { "name": "full", "in": "query", - "description": "Return full data including metadata, locks, and related entities.\nDefault is false for backward compatibility.", + "description": "Return full series data including metadata, locks, genres, tags, etc.", "required": false, "schema": { "type": "boolean" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SeriesListRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Paginated list of filtered series (returns FullSeriesListResponse when full=true)", + "description": "List of recently added series in library (returns Vec when full=true)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesDto" + } } } } @@ -5201,14 +5386,24 @@ ] } }, - "/api/v1/series/recently-added": { + "/api/v1/libraries/{library_id}/series/recently-updated": { "get": { "tags": [ "Series" ], - "summary": "List recently added series", - "operationId": "list_recently_added_series", + "summary": "List recently updated series in a specific library", + "operationId": "list_library_recently_updated_series", "parameters": [ + { + "name": "library_id", + "in": "path", + "description": "Library ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, { "name": "limit", "in": "query", @@ -5245,7 +5440,7 @@ ], "responses": { "200": { - "description": "List of recently added series (returns Vec when full=true)", + "description": "List of recently updated series in library (returns Vec when full=true)", "content": { "application/json": { "schema": { @@ -5271,69 +5466,57 @@ ] } }, - "/api/v1/series/recently-updated": { - "get": { + "/api/v1/libraries/{library_id}/series/thumbnails/generate": { + "post": { "tags": [ - "Series" + "Thumbnails" ], - "summary": "List recently updated series", - "operationId": "list_recently_updated_series", + "summary": "Generate thumbnails for all series in a library", + "description": "Queues a fan-out task that generates thumbnails for all series in the specified library.\n\n# Permission Required\n- `tasks:write`", + "operationId": "generate_library_series_thumbnails", "parameters": [ { - "name": "limit", - "in": "query", - "description": "Maximum number of series to return (default: 50)", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, - { - "name": "libraryId", - "in": "query", - "description": "Filter by library ID (optional)", - "required": false, + "name": "library_id", + "in": "path", + "description": "Library ID", + "required": true, "schema": { - "type": [ - "string", - "null" - ], + "type": "string", "format": "uuid" } - }, - { - "name": "full", - "in": "query", - "description": "Return full series data including metadata, locks, genres, tags, etc.", - "required": false, - "schema": { - "type": "boolean" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForceRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "List of recently updated series (returns Vec when full=true)", + "description": "Series thumbnail generation task queued", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SeriesDto" - } + "$ref": "#/components/schemas/CreateTaskResponse" } } } }, "403": { - "description": "Forbidden" + "description": "Permission denied" + }, + "404": { + "description": "Library not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -5341,44 +5524,32 @@ ] } }, - "/api/v1/series/search": { - "post": { + "/api/v1/metrics/inventory": { + "get": { "tags": [ - "Series" + "Metrics" ], - "summary": "Search series by name", - "operationId": "search_series", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SearchSeriesRequest" - } - } - }, - "required": true - }, + "summary": "Get inventory metrics (library/book counts)", + "description": "Returns counts and sizes for libraries, series, and books in the system.\nThis endpoint provides an inventory overview of your digital library.\n\n# Permission Required\n- `libraries:read` or admin status", + "operationId": "get_inventory_metrics", "responses": { "200": { - "description": "Search results (returns Vec when full=true)", + "description": "Inventory metrics retrieved successfully", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SeriesDto" - } + "$ref": "#/components/schemas/MetricsDto" } } } }, "403": { - "description": "Forbidden" + "description": "Permission denied" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -5386,52 +5557,32 @@ ] } }, - "/api/v1/series/{series_id}": { + "/api/v1/metrics/plugins": { "get": { "tags": [ - "Series" - ], - "summary": "Get series by ID", - "operationId": "get_series", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "full", - "in": "query", - "description": "Return full series data including metadata, locks, genres, tags, etc.", - "required": false, - "schema": { - "type": "boolean" - } - } + "Metrics" ], + "summary": "Get plugin metrics", + "description": "Returns real-time performance statistics for all plugins including:\n- Summary metrics across all plugins\n- Per-plugin breakdown with timing, error rates, and health status\n- Per-method breakdown within each plugin\n\n# Permission Required\n- `libraries:read` or admin status", + "operationId": "get_plugin_metrics", "responses": { "200": { - "description": "Series details (returns FullSeriesResponse when full=true)", + "description": "Plugin metrics retrieved successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SeriesDto" + "$ref": "#/components/schemas/PluginMetricsResponse" } } } }, - "404": { - "description": "Series not found" + "403": { + "description": "Permission denied" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -5439,101 +5590,105 @@ ] } }, - "/api/v1/series/{series_id}/alternate-titles": { + "/api/v1/metrics/tasks": { "get": { "tags": [ - "Series" - ], - "summary": "Get alternate titles for a series", - "operationId": "get_series_alternate_titles", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Metrics" ], + "summary": "Get current task metrics", + "description": "Returns real-time task performance statistics including:\n- Summary metrics across all task types\n- Per-task-type breakdown with timing, throughput, and error rates\n- Queue health metrics (pending, processing, stale counts)\n\n# Permission Required\n- `libraries:read` or admin status", + "operationId": "get_task_metrics", "responses": { "200": { - "description": "List of alternate titles for the series", + "description": "Task metrics retrieved successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AlternateTitleListResponse" + "$ref": "#/components/schemas/TaskMetricsResponse" } } } }, "403": { - "description": "Forbidden" + "description": "Permission denied" }, - "404": { - "description": "Series not found" + "503": { + "description": "Task metrics service not available" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] }, - "post": { + "delete": { "tags": [ - "Series" + "Metrics" ], - "summary": "Add an alternate title to a series", - "operationId": "create_alternate_title", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateAlternateTitleRequest" + "summary": "Delete all task metrics", + "description": "Permanently deletes all task metric records from the database\nand clears in-memory aggregates. This action cannot be undone.\n\n# Permission Required\n- Admin status required", + "operationId": "nuke_task_metrics", + "responses": { + "200": { + "description": "All metrics deleted successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetricsNukeResponse" + } } } }, - "required": true + "403": { + "description": "Permission denied - admin required" + }, + "503": { + "description": "Task metrics service not available" + } }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/metrics/tasks/cleanup": { + "post": { + "tags": [ + "Metrics" + ], + "summary": "Trigger manual metrics cleanup", + "description": "Deletes metric records older than the configured retention period.\nThis operation normally runs automatically daily.\n\n# Permission Required\n- Admin status required", + "operationId": "trigger_metrics_cleanup", "responses": { - "201": { - "description": "Alternate title created", + "200": { + "description": "Cleanup completed successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AlternateTitleDto" + "$ref": "#/components/schemas/MetricsCleanupResponse" } } } }, "403": { - "description": "Forbidden" + "description": "Permission denied - admin required" }, - "404": { - "description": "Series not found" + "503": { + "description": "Task metrics service not available" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -5541,114 +5696,137 @@ ] } }, - "/api/v1/series/{series_id}/alternate-titles/{title_id}": { - "delete": { + "/api/v1/metrics/tasks/history": { + "get": { "tags": [ - "Series" + "Metrics" ], - "summary": "Delete an alternate title", - "operationId": "delete_alternate_title", + "summary": "Get task metrics history", + "description": "Returns historical task performance data for trend analysis.\nData is aggregated by hour or day depending on the granularity parameter.\n\n# Permission Required\n- `libraries:read` or admin status", + "operationId": "get_task_metrics_history", "parameters": [ { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, + "name": "days", + "in": "query", + "description": "Number of days to retrieve (default: 7)", + "required": false, "schema": { - "type": "string", - "format": "uuid" - } + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "example": 7 }, { - "name": "title_id", - "in": "path", - "description": "Alternate title ID", - "required": true, + "name": "taskType", + "in": "query", + "description": "Filter by task type", + "required": false, "schema": { - "type": "string", - "format": "uuid" - } + "type": [ + "string", + "null" + ] + }, + "example": "scan_library" + }, + { + "name": "granularity", + "in": "query", + "description": "Granularity: \"hour\" or \"day\" (default: hour)", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + }, + "example": "hour" } ], "responses": { - "204": { - "description": "Alternate title deleted" + "200": { + "description": "Task metrics history retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskMetricsHistoryResponse" + } + } + } }, "403": { - "description": "Forbidden" + "description": "Permission denied" }, - "404": { - "description": "Series or title not found" + "503": { + "description": "Task metrics service not available" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - }, - "patch": { + } + }, + "/api/v1/plugins/actions": { + "get": { "tags": [ - "Series" + "Plugin Actions" ], - "summary": "Update an alternate title", - "operationId": "update_alternate_title", + "summary": "Get available plugin actions for a scope", + "description": "Returns a list of available plugin actions for the specified scope.\nThis is used by the UI to populate dropdown menus with available plugins.", + "operationId": "get_plugin_actions", "parameters": [ { - "name": "series_id", - "in": "path", - "description": "Series ID", + "name": "scope", + "in": "query", + "description": "Scope to filter actions by (e.g., \"series:detail\", \"series:bulk\")", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } }, { - "name": "title_id", - "in": "path", - "description": "Alternate title ID", - "required": true, + "name": "libraryId", + "in": "query", + "description": "Optional library ID to filter plugins by. When provided, only plugins that\napply to this library (or all libraries) will be returned.", + "required": false, "schema": { - "type": "string", + "type": [ + "string", + "null" + ], "format": "uuid" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateAlternateTitleRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Alternate title updated", + "description": "Plugin actions retrieved", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AlternateTitleDto" + "$ref": "#/components/schemas/PluginActionsResponse" } } } }, - "403": { - "description": "Forbidden" + "400": { + "description": "Invalid scope" }, - "404": { - "description": "Series or title not found" + "401": { + "description": "Unauthorized" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -5656,19 +5834,19 @@ ] } }, - "/api/v1/series/{series_id}/analyze": { + "/api/v1/plugins/{id}/execute": { "post": { "tags": [ - "Scans" + "Plugin Actions" ], - "summary": "Trigger analysis of all books in a series", - "description": "# Permission Required\n- `series:write`\n\n# Behavior\nEnqueues an AnalyzeSeries task which will create individual AnalyzeBook tasks\nfor each book in the series. All books are analyzed with force=true.\nReturns immediately with a task_id to track progress.", - "operationId": "trigger_series_analysis", + "summary": "Execute a plugin action", + "description": "Invokes a plugin action and returns the result. Actions are typed by plugin type:\n- `metadata`: search, get, match (requires write permission for the content_type)\n- `ping`: health check (requires PluginsManage permission)", + "operationId": "execute_plugin", "parameters": [ { - "name": "series_id", + "name": "id", "in": "path", - "description": "Series ID", + "description": "Plugin ID", "required": true, "schema": { "type": "string", @@ -5676,22 +5854,38 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecutePluginRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Analysis task enqueued successfully", + "description": "Action executed", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTaskResponse" + "$ref": "#/components/schemas/ExecutePluginResponse" } } } }, + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Permission denied" + "description": "Insufficient permission for this action" }, "404": { - "description": "Series not found" + "description": "Plugin not found" } }, "security": [ @@ -5704,42 +5898,29 @@ ] } }, - "/api/v1/series/{series_id}/analyze-unanalyzed": { - "post": { + "/api/v1/progress": { + "get": { "tags": [ - "Scans" - ], - "summary": "Trigger analysis of unanalyzed books in a series", - "description": "# Permission Required\n- `series:write`\n\n# Behavior\nEnqueues AnalyzeBook tasks (with force=false) for books in the series that have not been analyzed yet.\nThis is useful for recovering from failures or analyzing newly discovered books.\nReturns immediately with a task_id to track progress.", - "operationId": "trigger_series_unanalyzed_analysis", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Reading Progress" ], + "summary": "Get all reading progress for the authenticated user", + "operationId": "get_user_progress", "responses": { "200": { - "description": "Analysis tasks enqueued successfully", + "description": "User reading progress retrieved", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTaskResponse" + "$ref": "#/components/schemas/ReadProgressListResponse" } } } }, - "403": { - "description": "Permission denied" + "401": { + "description": "Unauthorized" }, - "404": { - "description": "Series not found" + "403": { + "description": "Forbidden" } }, "security": [ @@ -5752,67 +5933,61 @@ ] } }, - "/api/v1/series/{series_id}/books": { + "/api/v1/scans/active": { "get": { "tags": [ - "Series" - ], - "summary": "Get books in a series", - "operationId": "get_series_books", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "includeDeleted", - "in": "query", - "description": "Include deleted books in the result", - "required": false, - "schema": { - "type": "boolean" - } - }, - { - "name": "full", - "in": "query", - "description": "Return full data including metadata and locks.\nDefault is false for backward compatibility.", - "required": false, - "schema": { - "type": "boolean" - } - } + "Scans" ], + "summary": "List all active scans", + "description": "# Permission Required\n- `libraries:read`", + "operationId": "list_active_scans", "responses": { "200": { - "description": "List of books in the series (returns Vec when full=true)", + "description": "List of active scans", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/BookDto" + "$ref": "#/components/schemas/ScanStatusDto" } } } } }, "403": { - "description": "Forbidden" + "description": "Permission denied" + } + }, + "security": [ + { + "bearer_auth": [] }, - "404": { - "description": "Series not found" + { + "api_key": [] + } + ] + } + }, + "/api/v1/scans/stream": { + "get": { + "tags": [ + "Scans" + ], + "summary": "Stream scan progress updates via Server-Sent Events", + "description": "# Permission Required\n- `libraries:read`\n\n**DEPRECATED**: This endpoint is replaced by `/api/v1/tasks/stream` which provides\nreal-time updates for all task types including scans. This endpoint now filters\nthe task stream to only show scan_library tasks for backwards compatibility.", + "operationId": "scan_progress_stream", + "responses": { + "200": { + "description": "SSE stream of scan progress updates" + }, + "403": { + "description": "Permission denied" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -5820,76 +5995,98 @@ ] } }, - "/api/v1/series/{series_id}/books/with-errors": { + "/api/v1/series": { "get": { "tags": [ - "Books" + "Series" ], - "summary": "List books with analysis errors in a specific series", - "operationId": "list_series_books_with_errors", + "summary": "List series with optional library filter and pagination", + "operationId": "list_series", "parameters": [ { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, + "name": "page", + "in": "query", + "description": "Page number (1-indexed, default 1)", + "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "format": "int64", + "minimum": 0 } }, { - "name": "libraryId", + "name": "pageSize", "in": "query", - "description": "Optional library filter", + "description": "Number of items per page (max 100, default 50)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "sort", + "in": "query", + "description": "Sort parameter (format: \"field,direction\" e.g. \"name,asc\")", "required": false, "schema": { "type": [ "string", "null" - ], - "format": "uuid" + ] } }, { - "name": "seriesId", + "name": "genres", "in": "query", - "description": "Optional series filter", + "description": "Filter by genres (comma-separated, AND logic - series must have ALL specified genres)", "required": false, "schema": { "type": [ "string", "null" - ], - "format": "uuid" + ] } }, { - "name": "page", + "name": "tags", "in": "query", - "description": "Page number (1-indexed, minimum 1)", + "description": "Filter by tags (comma-separated, AND logic - series must have ALL specified tags)", "required": false, "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": [ + "string", + "null" + ] } }, { - "name": "pageSize", + "name": "libraryId", "in": "query", - "description": "Number of items per page (max 100, default 50)", + "description": "Filter by library ID", "required": false, "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": [ + "string", + "null" + ], + "format": "uuid" + } + }, + { + "name": "full", + "in": "query", + "description": "Return full series data including metadata, locks, genres, tags, alternate titles,\nexternal ratings, and external links. Default is false for backward compatibility.", + "required": false, + "schema": { + "type": "boolean" } } ], "responses": { "200": { - "description": "Paginated list of books with analysis errors in series", + "description": "Paginated list of series (returns FullSeriesListResponse when full=true)", "content": { "application/json": { "schema": { @@ -5912,31 +6109,19 @@ ] } }, - "/api/v1/series/{series_id}/cover": { + "/api/v1/series/bulk/analyze": { "post": { "tags": [ - "Series" - ], - "summary": "Upload a custom cover/poster for a series", - "operationId": "upload_series_cover", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Bulk Operations" ], + "summary": "Bulk analyze multiple series", + "description": "Enqueues analysis tasks for all books in the specified series.\nSeries that don't exist are silently skipped.", + "operationId": "bulk_analyze_series", "requestBody": { - "description": "Multipart form with image file", "content": { - "multipart/form-data": { + "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/BulkAnalyzeSeriesRequest" } } }, @@ -5944,21 +6129,25 @@ }, "responses": { "200": { - "description": "Cover uploaded successfully" + "description": "Analysis tasks enqueued", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkAnalyzeResponse" + } + } + } }, - "400": { - "description": "Invalid image or request" + "401": { + "description": "Unauthorized" }, "403": { "description": "Forbidden" - }, - "404": { - "description": "Series not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -5966,30 +6155,19 @@ ] } }, - "/api/v1/series/{series_id}/cover/source": { - "patch": { + "/api/v1/series/bulk/read": { + "post": { "tags": [ - "Series" - ], - "summary": "Set which cover source to use for a series (partial update)", - "operationId": "set_series_cover_source", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Bulk Operations" ], + "summary": "Bulk mark multiple series as read", + "description": "Marks all books in the specified series as read for the authenticated user.\nSeries that don't exist are silently skipped.", + "operationId": "bulk_mark_series_as_read", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SelectCoverSourceRequest" + "$ref": "#/components/schemas/BulkSeriesRequest" } } }, @@ -5997,18 +6175,25 @@ }, "responses": { "200": { - "description": "Cover source updated successfully" + "description": "Series marked as read", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MarkReadResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" }, "403": { "description": "Forbidden" - }, - "404": { - "description": "Series not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -6016,46 +6201,45 @@ ] } }, - "/api/v1/series/{series_id}/covers": { - "get": { + "/api/v1/series/bulk/unread": { + "post": { "tags": [ - "Series" + "Bulk Operations" ], - "summary": "List all covers for a series", - "operationId": "list_series_covers", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" + "summary": "Bulk mark multiple series as unread", + "description": "Marks all books in the specified series as unread for the authenticated user.\nSeries that don't exist are silently skipped.", + "operationId": "bulk_mark_series_as_unread", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkSeriesRequest" + } } - } - ], + }, + "required": true + }, "responses": { "200": { - "description": "List of series covers", + "description": "Series marked as unread", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SeriesCoverListResponse" + "$ref": "#/components/schemas/MarkReadResponse" } } } }, + "401": { + "description": "Unauthorized" + }, "403": { "description": "Forbidden" - }, - "404": { - "description": "Series not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -6063,34 +6247,53 @@ ] } }, - "/api/v1/series/{series_id}/covers/selected": { - "delete": { + "/api/v1/series/in-progress": { + "get": { "tags": [ "Series" ], - "summary": "Reset series cover to default (deselect all custom covers)", - "operationId": "reset_series_cover", + "summary": "List series with in-progress books (series that have at least one book with reading progress that is not completed)", + "operationId": "list_in_progress_series", "parameters": [ { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, + "name": "libraryId", + "in": "query", + "description": "Filter by library ID (optional)", + "required": false, "schema": { - "type": "string", + "type": [ + "string", + "null" + ], "format": "uuid" } + }, + { + "name": "full", + "in": "query", + "description": "Return full series data including metadata, locks, genres, tags, etc.", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { - "204": { - "description": "Reset to default cover successfully" + "200": { + "description": "List of in-progress series (returns Vec when full=true)", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesDto" + } + } + } + } }, "403": { "description": "Forbidden" - }, - "404": { - "description": "Series not found" } }, "security": [ @@ -6103,104 +6306,85 @@ ] } }, - "/api/v1/series/{series_id}/covers/{cover_id}": { - "delete": { + "/api/v1/series/list": { + "post": { "tags": [ "Series" ], - "summary": "Delete a cover from a series", - "operationId": "delete_series_cover", + "summary": "List series with advanced filtering", + "description": "Supports complex filter conditions including nested AllOf/AnyOf logic,\ngenre/tag filtering with include/exclude, and more.\n\nPagination parameters (page, pageSize, sort) are passed as query parameters.\nFilter conditions are passed in the request body.", + "operationId": "list_series_filtered", "parameters": [ { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, + "name": "page", + "in": "query", + "description": "Page number (1-indexed, minimum 1)", + "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "format": "int64", + "default": 1, + "minimum": 1 } }, { - "name": "cover_id", - "in": "path", - "description": "Cover ID to delete", - "required": true, + "name": "pageSize", + "in": "query", + "description": "Number of items per page (max 500, default 50)", + "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "format": "int64", + "default": 50, + "maximum": 500, + "minimum": 1 } - } - ], - "responses": { - "204": { - "description": "Cover deleted successfully" - }, - "400": { - "description": "Cannot delete the only selected cover" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Series or cover not found" - } - }, - "security": [ - { - "jwt_bearer": [] }, { - "api_key": [] - } - ] - } - }, - "/api/v1/series/{series_id}/covers/{cover_id}/image": { - "get": { - "tags": [ - "Series" - ], - "summary": "Get a specific cover image for a series", - "description": "Supports HTTP conditional caching with ETag and Last-Modified headers,\nreturning 304 Not Modified when the client has a valid cached copy.", - "operationId": "get_series_cover_image", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, + "name": "sort", + "in": "query", + "description": "Sort field and direction (e.g., \"name,asc\" or \"createdAt,desc\")", + "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": [ + "string", + "null" + ] } }, { - "name": "cover_id", - "in": "path", - "description": "Cover ID", - "required": true, + "name": "full", + "in": "query", + "description": "Return full data including metadata, locks, and related entities.\nDefault is false for backward compatibility.", + "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": "boolean" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SeriesListRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Cover image", + "description": "Paginated list of filtered series (returns FullSeriesListResponse when full=true)", "content": { - "image/jpeg": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse" + } + } } }, - "304": { - "description": "Not modified (client cache is valid)" - }, "403": { "description": "Forbidden" - }, - "404": { - "description": "Series or cover not found" } }, "security": [ @@ -6213,51 +6397,40 @@ ] } }, - "/api/v1/series/{series_id}/covers/{cover_id}/select": { - "put": { + "/api/v1/series/list/alphabetical-groups": { + "post": { "tags": [ "Series" ], - "summary": "Select a cover as the primary cover for a series", - "operationId": "select_series_cover", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" + "summary": "Get alphabetical groups for series", + "description": "Returns a list of alphabetical groups with counts, showing how many series\nstart with each letter/character. This is useful for building A-Z navigation.\nThe same filters as list_series_filtered can be applied.", + "operationId": "list_series_alphabetical_groups", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SeriesListRequest" + } } }, - { - "name": "cover_id", - "in": "path", - "description": "Cover ID to select", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], + "required": true + }, "responses": { "200": { - "description": "Cover selected successfully", + "description": "List of alphabetical groups with counts", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SeriesCoverDto" + "type": "array", + "items": { + "$ref": "#/components/schemas/AlphabeticalGroupDto" + } } } } }, "403": { "description": "Forbidden" - }, - "404": { - "description": "Series or cover not found" } }, "security": [ @@ -6270,43 +6443,51 @@ ] } }, - "/api/v1/series/{series_id}/download": { - "get": { + "/api/v1/series/metadata/auto-match/task/bulk": { + "post": { "tags": [ - "Series" + "Plugin Actions" ], - "summary": "Download all books in a series as a zip file", - "description": "Creates a zip archive containing all detected books in the series.\nOnly includes books that were scanned and detected by the library scanner.", - "operationId": "download_series", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" + "summary": "Enqueue plugin auto-match tasks for multiple series (bulk operation)", + "description": "Creates background tasks to auto-match metadata for multiple series using the specified plugin.\nEach series gets its own task that runs asynchronously in a worker process.", + "operationId": "enqueue_bulk_auto_match_tasks", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnqueueBulkAutoMatchRequest" + } } - } - ], + }, + "required": true + }, "responses": { "200": { - "description": "Zip file containing all books in the series", + "description": "Tasks enqueued", "content": { - "application/zip": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnqueueAutoMatchResponse" + } + } } }, + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Forbidden" + "description": "No permission to edit series" }, "404": { - "description": "Series not found or has no books" + "description": "Plugin not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -6314,41 +6495,64 @@ ] } }, - "/api/v1/series/{series_id}/external-links": { + "/api/v1/series/recently-added": { "get": { "tags": [ "Series" ], - "summary": "Get external links for a series", - "operationId": "get_series_external_links", + "summary": "List recently added series", + "operationId": "list_recently_added_series", "parameters": [ { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, + "name": "limit", + "in": "query", + "description": "Maximum number of series to return (default: 50)", + "required": false, "schema": { - "type": "string", + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "libraryId", + "in": "query", + "description": "Filter by library ID (optional)", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], "format": "uuid" } + }, + { + "name": "full", + "in": "query", + "description": "Return full series data including metadata, locks, genres, tags, etc.", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { "200": { - "description": "List of external links for the series", + "description": "List of recently added series (returns Vec when full=true)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExternalLinkListResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesDto" + } } } } }, "403": { "description": "Forbidden" - }, - "404": { - "description": "Series not found" } }, "security": [ @@ -6359,30 +6563,90 @@ "api_key": [] } ] - }, - "post": { + } + }, + "/api/v1/series/recently-updated": { + "get": { "tags": [ "Series" ], - "summary": "Add or update an external link for a series", - "operationId": "create_external_link", + "summary": "List recently updated series", + "operationId": "list_recently_updated_series", "parameters": [ { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, + "name": "limit", + "in": "query", + "description": "Maximum number of series to return (default: 50)", + "required": false, "schema": { - "type": "string", + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "libraryId", + "in": "query", + "description": "Filter by library ID (optional)", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], "format": "uuid" } + }, + { + "name": "full", + "in": "query", + "description": "Return full series data including metadata, locks, genres, tags, etc.", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "List of recently updated series (returns Vec when full=true)", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesDto" + } + } + } + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] } + ] + } + }, + "/api/v1/series/search": { + "post": { + "tags": [ + "Series" ], + "summary": "Search series by name", + "operationId": "search_series", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateExternalLinkRequest" + "$ref": "#/components/schemas/SearchSeriesRequest" } } }, @@ -6390,20 +6654,20 @@ }, "responses": { "200": { - "description": "External link created or updated", + "description": "Search results (returns Vec when full=true)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExternalLinkDto" + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesDto" + } } } } }, "403": { - "description": "Forbidden - admin only" - }, - "404": { - "description": "Series not found" + "description": "Forbidden" } }, "security": [ @@ -6416,48 +6680,42 @@ ] } }, - "/api/v1/series/{series_id}/external-links/{source}": { - "delete": { + "/api/v1/series/thumbnails/generate": { + "post": { "tags": [ - "Series" + "Thumbnails" ], - "summary": "Delete an external link by source name", - "operationId": "delete_external_link", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" + "summary": "Generate thumbnails for series in a scope", + "description": "This queues a fan-out task that enqueues individual series thumbnail generation tasks.\nSeries thumbnails are the cover images displayed for each series (derived from the first book's cover).\n\n**Scope:**\n- If `library_id` is provided, only series in that library\n- If not provided, all series in all libraries\n\n**Force behavior:**\n- `force: false` (default): Only generates thumbnails for series that don't have one\n- `force: true`: Regenerates all thumbnails, replacing existing ones\n\n# Permission Required\n- `tasks:write`", + "operationId": "generate_series_thumbnails", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateSeriesThumbnailsRequest" + } } }, - { - "name": "source", - "in": "path", - "description": "Source name (e.g., 'myanimelist', 'mangadex')", - "required": true, - "schema": { - "type": "string" - } - } - ], + "required": true + }, "responses": { - "204": { - "description": "External link deleted" + "200": { + "description": "Series thumbnail generation task queued", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTaskResponse" + } + } + } }, "403": { - "description": "Forbidden - admin only" - }, - "404": { - "description": "Series or link not found" + "description": "Permission denied" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -6465,16 +6723,17 @@ ] } }, - "/api/v1/series/{series_id}/external-ratings": { - "get": { + "/api/v1/series/{id}/metadata/apply": { + "post": { "tags": [ - "Series" + "Plugin Actions" ], - "summary": "Get external ratings for a series", - "operationId": "get_series_external_ratings", + "summary": "Apply metadata from a plugin to a series", + "description": "Fetches metadata from a plugin and applies it to the series, respecting\nRBAC permissions and field locks.", + "operationId": "apply_series_metadata", "parameters": [ { - "name": "series_id", + "name": "id", "in": "path", "description": "Series ID", "required": true, @@ -6484,42 +6743,61 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataApplyRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "List of external ratings for the series", + "description": "Metadata applied", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExternalRatingListResponse" + "$ref": "#/components/schemas/MetadataApplyResponse" } } } }, + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Forbidden" + "description": "No permission to edit series" }, "404": { - "description": "Series not found" + "description": "Series or plugin not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - }, + } + }, + "/api/v1/series/{id}/metadata/auto-match": { "post": { "tags": [ - "Series" + "Plugin Actions" ], - "summary": "Add or update an external rating for a series", - "operationId": "create_external_rating", + "summary": "Auto-match and apply metadata from a plugin to a series", + "description": "Searches for the series using the plugin's metadata search, picks the best match,\nand applies the metadata in one step. This is a convenience endpoint for quick\nmetadata updates without user intervention.", + "operationId": "auto_match_series_metadata", "parameters": [ { - "name": "series_id", + "name": "id", "in": "path", "description": "Series ID", "required": true, @@ -6533,7 +6811,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateExternalRatingRequest" + "$ref": "#/components/schemas/MetadataAutoMatchRequest" } } }, @@ -6541,25 +6819,31 @@ }, "responses": { "200": { - "description": "External rating created or updated", + "description": "Auto-match completed", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExternalRatingDto" + "$ref": "#/components/schemas/MetadataAutoMatchResponse" } } } }, + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Forbidden - admin only" + "description": "No permission to edit series" }, "404": { - "description": "Series not found" + "description": "Series or plugin not found or no match found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -6567,16 +6851,17 @@ ] } }, - "/api/v1/series/{series_id}/external-ratings/{source}": { - "delete": { + "/api/v1/series/{id}/metadata/auto-match/task": { + "post": { "tags": [ - "Series" + "Plugin Actions" ], - "summary": "Delete an external rating by source name", - "operationId": "delete_external_rating", + "summary": "Enqueue a plugin auto-match task for a single series", + "description": "Creates a background task to auto-match metadata for a series using the specified plugin.\nThe task runs asynchronously in a worker process and emits a SeriesMetadataUpdated event\nwhen complete.", + "operationId": "enqueue_auto_match_task", "parameters": [ { - "name": "series_id", + "name": "id", "in": "path", "description": "Series ID", "required": true, @@ -6584,31 +6869,45 @@ "type": "string", "format": "uuid" } - }, - { - "name": "source", - "in": "path", - "description": "Source name (e.g., 'myanimelist', 'anilist')", - "required": true, - "schema": { - "type": "string" - } } ], - "responses": { - "204": { - "description": "External rating deleted" - }, - "403": { - "description": "Forbidden - admin only" + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnqueueAutoMatchRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Task enqueued", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnqueueAutoMatchResponse" + } + } + } + }, + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "No permission to edit series" }, "404": { - "description": "Series or rating not found" + "description": "Series or plugin not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -6616,16 +6915,17 @@ ] } }, - "/api/v1/series/{series_id}/genres": { - "get": { + "/api/v1/series/{id}/metadata/preview": { + "post": { "tags": [ - "Genres" + "Plugin Actions" ], - "summary": "Get genres for a series", - "operationId": "get_series_genres", + "summary": "Preview metadata from a plugin for a series", + "description": "Fetches metadata from a plugin and computes a field-by-field diff with the current\nseries metadata, showing which fields will be applied, locked, or denied by RBAC.", + "operationId": "preview_series_metadata", "parameters": [ { - "name": "series_id", + "name": "id", "in": "path", "description": "Series ID", "required": true, @@ -6635,39 +6935,57 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataPreviewRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "List of genres for the series", + "description": "Preview computed", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GenreListResponse" + "$ref": "#/components/schemas/MetadataPreviewResponse" } } } }, + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Unauthorized" + }, "403": { - "description": "Forbidden" + "description": "No permission to edit series" }, "404": { - "description": "Series not found" + "description": "Series or plugin not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - }, - "put": { + } + }, + "/api/v1/series/{series_id}": { + "get": { "tags": [ - "Genres" + "Series" ], - "summary": "Set genres for a series (replaces existing)", - "operationId": "set_series_genres", + "summary": "Get series by ID", + "operationId": "get_series", "parameters": [ { "name": "series_id", @@ -6678,32 +6996,28 @@ "type": "string", "format": "uuid" } + }, + { + "name": "full", + "in": "query", + "description": "Return full series data including metadata, locks, genres, tags, etc.", + "required": false, + "schema": { + "type": "boolean" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SetSeriesGenresRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Genres updated", + "description": "Series details (returns FullSeriesResponse when full=true)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GenreListResponse" + "$ref": "#/components/schemas/SeriesDto" } } } }, - "403": { - "description": "Forbidden" - }, "404": { "description": "Series not found" } @@ -6717,12 +7031,13 @@ } ] }, - "post": { + "patch": { "tags": [ - "Genres" + "Series" ], - "summary": "Add a single genre to a series", - "operationId": "add_series_genre", + "summary": "Update series core fields (name/title)", + "description": "Partially updates series_metadata fields. Only provided fields will be updated.\nAbsent fields are unchanged. When name is set to a non-null value, it is automatically locked.", + "operationId": "patch_series", "parameters": [ { "name": "series_id", @@ -6739,7 +7054,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AddSeriesGenreRequest" + "$ref": "#/components/schemas/PatchSeriesRequest" } } }, @@ -6747,11 +7062,11 @@ }, "responses": { "200": { - "description": "Genre added", + "description": "Series updated successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GenreDto" + "$ref": "#/components/schemas/SeriesUpdateResponse" } } } @@ -6773,13 +7088,13 @@ ] } }, - "/api/v1/series/{series_id}/genres/{genre_id}": { - "delete": { + "/api/v1/series/{series_id}/alternate-titles": { + "get": { "tags": [ - "Genres" + "Series" ], - "summary": "Remove a genre from a series", - "operationId": "remove_series_genre", + "summary": "Get alternate titles for a series", + "operationId": "get_series_alternate_titles", "parameters": [ { "name": "series_id", @@ -6790,27 +7105,24 @@ "type": "string", "format": "uuid" } - }, - { - "name": "genre_id", - "in": "path", - "description": "Genre ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } } ], "responses": { - "204": { - "description": "Genre removed from series" + "200": { + "description": "List of alternate titles for the series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlternateTitleListResponse" + } + } + } }, "403": { "description": "Forbidden" }, "404": { - "description": "Series or genre link not found" + "description": "Series not found" } }, "security": [ @@ -6821,16 +7133,13 @@ "api_key": [] } ] - } - }, - "/api/v1/series/{series_id}/metadata": { - "get": { + }, + "post": { "tags": [ "Series" ], - "summary": "Get series metadata including all related data", - "description": "Returns comprehensive metadata with lock states, genres, tags, alternate titles,\nexternal ratings, and external links.", - "operationId": "get_series_metadata", + "summary": "Add an alternate title to a series", + "operationId": "create_alternate_title", "parameters": [ { "name": "series_id", @@ -6843,13 +7152,23 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAlternateTitleRequest" + } + } + }, + "required": true + }, "responses": { - "200": { - "description": "Series metadata with all related data", + "201": { + "description": "Alternate title created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FullSeriesMetadataResponse" + "$ref": "#/components/schemas/AlternateTitleDto" } } } @@ -6869,14 +7188,15 @@ "api_key": [] } ] - }, - "put": { + } + }, + "/api/v1/series/{series_id}/alternate-titles/{title_id}": { + "delete": { "tags": [ "Series" ], - "summary": "Replace all series metadata (PUT)", - "description": "Replaces all metadata fields with the values in the request.\nOmitting a field (or setting it to null) will clear that field.", - "operationId": "replace_series_metadata", + "summary": "Delete an alternate title", + "operationId": "delete_alternate_title", "parameters": [ { "name": "series_id", @@ -6887,34 +7207,27 @@ "type": "string", "format": "uuid" } + }, + { + "name": "title_id", + "in": "path", + "description": "Alternate title ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReplaceSeriesMetadataRequest" - } - } - }, - "required": true - }, "responses": { - "200": { - "description": "Metadata replaced successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SeriesMetadataResponse" - } - } - } + "204": { + "description": "Alternate title deleted" }, "403": { "description": "Forbidden" }, "404": { - "description": "Series not found" + "description": "Series or title not found" } }, "security": [ @@ -6930,9 +7243,8 @@ "tags": [ "Series" ], - "summary": "Partially update series metadata (PATCH)", - "description": "Only provided fields will be updated. Absent fields are unchanged.\nExplicitly null fields will be cleared.", - "operationId": "patch_series_metadata", + "summary": "Update an alternate title", + "operationId": "update_alternate_title", "parameters": [ { "name": "series_id", @@ -6943,13 +7255,23 @@ "type": "string", "format": "uuid" } + }, + { + "name": "title_id", + "in": "path", + "description": "Alternate title ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchSeriesMetadataRequest" + "$ref": "#/components/schemas/UpdateAlternateTitleRequest" } } }, @@ -6957,11 +7279,11 @@ }, "responses": { "200": { - "description": "Metadata updated successfully", + "description": "Alternate title updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SeriesMetadataResponse" + "$ref": "#/components/schemas/AlternateTitleDto" } } } @@ -6970,7 +7292,7 @@ "description": "Forbidden" }, "404": { - "description": "Series not found" + "description": "Series or title not found" } }, "security": [ @@ -6983,13 +7305,14 @@ ] } }, - "/api/v1/series/{series_id}/metadata/locks": { - "get": { + "/api/v1/series/{series_id}/analyze": { + "post": { "tags": [ - "Series" + "Scans" ], - "summary": "Get metadata lock states", - "operationId": "get_metadata_locks", + "summary": "Trigger analysis of all books in a series", + "description": "# Permission Required\n- `series:write`\n\n# Behavior\nEnqueues an AnalyzeSeries task which will create individual AnalyzeBook tasks\nfor each book in the series. All books are analyzed with force=true.\nReturns immediately with a task_id to track progress.", + "operationId": "trigger_series_analysis", "parameters": [ { "name": "series_id", @@ -7004,17 +7327,17 @@ ], "responses": { "200": { - "description": "Current lock states", + "description": "Analysis task enqueued successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MetadataLocks" + "$ref": "#/components/schemas/CreateTaskResponse" } } } }, "403": { - "description": "Forbidden" + "description": "Permission denied" }, "404": { "description": "Series not found" @@ -7022,20 +7345,22 @@ }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - }, - "put": { + } + }, + "/api/v1/series/{series_id}/analyze-unanalyzed": { + "post": { "tags": [ - "Series" + "Scans" ], - "summary": "Update metadata lock states", - "description": "Sets which metadata fields are locked. Locked fields will not be overwritten\nby automatic metadata refresh from book analysis or external sources.", - "operationId": "update_metadata_locks", + "summary": "Trigger analysis of unanalyzed books in a series", + "description": "# Permission Required\n- `series:write`\n\n# Behavior\nEnqueues AnalyzeBook tasks (with force=false) for books in the series that have not been analyzed yet.\nThis is useful for recovering from failures or analyzing newly discovered books.\nReturns immediately with a task_id to track progress.", + "operationId": "trigger_series_unanalyzed_analysis", "parameters": [ { "name": "series_id", @@ -7048,29 +7373,19 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateMetadataLocksRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Lock states updated", + "description": "Analysis tasks enqueued successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MetadataLocks" + "$ref": "#/components/schemas/CreateTaskResponse" } } } }, "403": { - "description": "Forbidden" + "description": "Permission denied" }, "404": { "description": "Series not found" @@ -7078,7 +7393,7 @@ }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -7086,13 +7401,13 @@ ] } }, - "/api/v1/series/{series_id}/purge-deleted": { - "delete": { + "/api/v1/series/{series_id}/books": { + "get": { "tags": [ "Series" ], - "summary": "Purge deleted books from a series", - "operationId": "purge_series_deleted_books", + "summary": "Get books in a series", + "operationId": "get_series_books", "parameters": [ { "name": "series_id", @@ -7103,17 +7418,36 @@ "type": "string", "format": "uuid" } + }, + { + "name": "includeDeleted", + "in": "query", + "description": "Include deleted books in the result", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "name": "full", + "in": "query", + "description": "Return full data including metadata and locks.\nDefault is false for backward compatibility.", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { "200": { - "description": "Number of books purged", + "description": "List of books in the series (returns Vec when full=true)", "content": { - "text/plain": { + "application/json": { "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "array", + "items": { + "$ref": "#/components/schemas/BookDto" + } } } } @@ -7135,14 +7469,13 @@ ] } }, - "/api/v1/series/{series_id}/rating": { - "get": { + "/api/v1/series/{series_id}/cover": { + "post": { "tags": [ - "Ratings" + "Series" ], - "summary": "Get the current user's rating for a series", - "description": "Returns null if no rating exists (not a 404, since the series exists but has no rating)", - "operationId": "get_series_rating", + "summary": "Upload a custom cover/poster for a series", + "operationId": "upload_series_cover", "parameters": [ { "name": "series_id", @@ -7155,24 +7488,24 @@ } } ], - "responses": { - "200": { - "description": "User's rating for the series (null if not rated)", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/UserSeriesRatingDto" - } - ] - } + "requestBody": { + "description": "Multipart form with image file", + "content": { + "multipart/form-data": { + "schema": { + "type": "object" } } }, + "required": true + }, + "responses": { + "200": { + "description": "Cover uploaded successfully" + }, + "400": { + "description": "Invalid image or request" + }, "403": { "description": "Forbidden" }, @@ -7188,13 +7521,15 @@ "api_key": [] } ] - }, - "put": { + } + }, + "/api/v1/series/{series_id}/cover/source": { + "patch": { "tags": [ - "Ratings" + "Series" ], - "summary": "Set (create or update) the current user's rating for a series", - "operationId": "set_series_rating", + "summary": "Set which cover source to use for a series (partial update)", + "operationId": "set_series_cover_source", "parameters": [ { "name": "series_id", @@ -7211,7 +7546,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetUserRatingRequest" + "$ref": "#/components/schemas/SelectCoverSourceRequest" } } }, @@ -7219,17 +7554,7 @@ }, "responses": { "200": { - "description": "Rating saved", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserSeriesRatingDto" - } - } - } - }, - "400": { - "description": "Invalid rating value" + "description": "Cover source updated successfully" }, "403": { "description": "Forbidden" @@ -7246,13 +7571,15 @@ "api_key": [] } ] - }, - "delete": { + } + }, + "/api/v1/series/{series_id}/covers": { + "get": { "tags": [ - "Ratings" + "Series" ], - "summary": "Delete the current user's rating for a series", - "operationId": "delete_series_rating", + "summary": "List all covers for a series", + "operationId": "list_series_covers", "parameters": [ { "name": "series_id", @@ -7266,14 +7593,21 @@ } ], "responses": { - "204": { - "description": "Rating deleted" + "200": { + "description": "List of series covers", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SeriesCoverListResponse" + } + } + } }, "403": { "description": "Forbidden" }, "404": { - "description": "Series or rating not found" + "description": "Series not found" } }, "security": [ @@ -7286,14 +7620,13 @@ ] } }, - "/api/v1/series/{series_id}/ratings/average": { - "get": { + "/api/v1/series/{series_id}/covers/selected": { + "delete": { "tags": [ "Series" ], - "summary": "Get the average community rating for a series", - "description": "Returns the average rating from all users and the total count of ratings.\nRatings are stored on a 0-100 scale internally.", - "operationId": "get_series_average_rating", + "summary": "Reset series cover to default (deselect all custom covers)", + "operationId": "reset_series_cover", "parameters": [ { "name": "series_id", @@ -7307,19 +7640,8 @@ } ], "responses": { - "200": { - "description": "Average rating for the series", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SeriesAverageRatingResponse" - }, - "example": { - "average": 78.5, - "count": 15 - } - } - } + "204": { + "description": "Reset to default cover successfully" }, "403": { "description": "Forbidden" @@ -7338,13 +7660,13 @@ ] } }, - "/api/v1/series/{series_id}/read": { - "post": { + "/api/v1/series/{series_id}/covers/{cover_id}": { + "delete": { "tags": [ "Series" ], - "summary": "Mark all books in a series as read", - "operationId": "mark_series_as_read", + "summary": "Delete a cover from a series", + "operationId": "delete_series_cover", "parameters": [ { "name": "series_id", @@ -7355,24 +7677,30 @@ "type": "string", "format": "uuid" } + }, + { + "name": "cover_id", + "in": "path", + "description": "Cover ID to delete", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } } ], "responses": { - "200": { - "description": "Series marked as read", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MarkReadResponse" - } - } - } + "204": { + "description": "Cover deleted successfully" + }, + "400": { + "description": "Cannot delete the only selected cover" }, "403": { "description": "Forbidden" }, "404": { - "description": "Series not found" + "description": "Series or cover not found" } }, "security": [ @@ -7385,13 +7713,14 @@ ] } }, - "/api/v1/series/{series_id}/sharing-tags": { + "/api/v1/series/{series_id}/covers/{cover_id}/image": { "get": { "tags": [ - "Sharing Tags" + "Series" ], - "summary": "Get sharing tags for a series (admin only)", - "operationId": "get_series_sharing_tags", + "summary": "Get a specific cover image for a series", + "description": "Supports HTTP conditional caching with ETag and Last-Modified headers,\nreturning 304 Not Modified when the client has a valid cached copy.", + "operationId": "get_series_cover_image", "parameters": [ { "name": "series_id", @@ -7402,24 +7731,33 @@ "type": "string", "format": "uuid" } + }, + { + "name": "cover_id", + "in": "path", + "description": "Cover ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } } ], "responses": { "200": { - "description": "List of sharing tags for the series", + "description": "Cover image", "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SharingTagSummaryDto" - } - } - } + "image/jpeg": {} } }, + "304": { + "description": "Not modified (client cache is valid)" + }, "403": { - "description": "Forbidden - Missing permission" + "description": "Forbidden" + }, + "404": { + "description": "Series or cover not found" } }, "security": [ @@ -7430,13 +7768,15 @@ "api_key": [] } ] - }, + } + }, + "/api/v1/series/{series_id}/covers/{cover_id}/select": { "put": { "tags": [ - "Sharing Tags" + "Series" ], - "summary": "Set sharing tags for a series (replaces existing) (admin only)", - "operationId": "set_series_sharing_tags", + "summary": "Select a cover as the primary cover for a series", + "operationId": "select_series_cover", "parameters": [ { "name": "series_id", @@ -7447,56 +7787,11 @@ "type": "string", "format": "uuid" } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SetSeriesSharingTagsRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Sharing tags set", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SharingTagSummaryDto" - } - } - } - } - }, - "403": { - "description": "Forbidden - Missing permission" - } - }, - "security": [ - { - "jwt_bearer": [] }, { - "api_key": [] - } - ] - }, - "post": { - "tags": [ - "Sharing Tags" - ], - "summary": "Add a sharing tag to a series (admin only)", - "operationId": "add_series_sharing_tag", - "parameters": [ - { - "name": "series_id", + "name": "cover_id", "in": "path", - "description": "Series ID", + "description": "Cover ID to select", "required": true, "schema": { "type": "string", @@ -7504,75 +7799,22 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ModifySeriesSharingTagRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Sharing tag added" - }, - "400": { - "description": "Tag already assigned" - }, - "403": { - "description": "Forbidden - Missing permission" - } - }, - "security": [ - { - "jwt_bearer": [] - }, - { - "api_key": [] - } - ] - } - }, - "/api/v1/series/{series_id}/sharing-tags/{tag_id}": { - "delete": { - "tags": [ - "Sharing Tags" - ], - "summary": "Remove a sharing tag from a series (admin only)", - "operationId": "remove_series_sharing_tag", - "parameters": [ - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "tag_id", - "in": "path", - "description": "Sharing tag ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" + "description": "Cover selected successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SeriesCoverDto" + } + } } - } - ], - "responses": { - "204": { - "description": "Sharing tag removed" }, "403": { - "description": "Forbidden - Missing permission" + "description": "Forbidden" }, "404": { - "description": "Sharing tag not assigned to series" + "description": "Series or cover not found" } }, "security": [ @@ -7585,13 +7827,14 @@ ] } }, - "/api/v1/series/{series_id}/tags": { + "/api/v1/series/{series_id}/download": { "get": { "tags": [ - "Tags" + "Series" ], - "summary": "Get tags for a series", - "operationId": "get_series_tags", + "summary": "Download all books in a series as a zip file", + "description": "Creates a zip archive containing all detected books in the series.\nOnly includes books that were scanned and detected by the library scanner.", + "operationId": "download_series", "parameters": [ { "name": "series_id", @@ -7606,20 +7849,16 @@ ], "responses": { "200": { - "description": "List of tags for the series", + "description": "Zip file containing all books in the series", "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TagListResponse" - } - } + "application/zip": {} } }, "403": { "description": "Forbidden" }, "404": { - "description": "Series not found" + "description": "Series not found or has no books" } }, "security": [ @@ -7630,13 +7869,15 @@ "api_key": [] } ] - }, - "put": { + } + }, + "/api/v1/series/{series_id}/external-links": { + "get": { "tags": [ - "Tags" + "Series" ], - "summary": "Set tags for a series (replaces existing)", - "operationId": "set_series_tags", + "summary": "Get external links for a series", + "operationId": "get_series_external_links", "parameters": [ { "name": "series_id", @@ -7649,23 +7890,13 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SetSeriesTagsRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Tags updated", + "description": "List of external links for the series", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TagListResponse" + "$ref": "#/components/schemas/ExternalLinkListResponse" } } } @@ -7688,10 +7919,10 @@ }, "post": { "tags": [ - "Tags" + "Series" ], - "summary": "Add a single tag to a series", - "operationId": "add_series_tag", + "summary": "Add or update an external link for a series", + "operationId": "create_external_link", "parameters": [ { "name": "series_id", @@ -7708,7 +7939,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AddSeriesTagRequest" + "$ref": "#/components/schemas/CreateExternalLinkRequest" } } }, @@ -7716,17 +7947,17 @@ }, "responses": { "200": { - "description": "Tag added", + "description": "External link created or updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TagDto" + "$ref": "#/components/schemas/ExternalLinkDto" } } } }, "403": { - "description": "Forbidden" + "description": "Forbidden - admin only" }, "404": { "description": "Series not found" @@ -7742,13 +7973,13 @@ ] } }, - "/api/v1/series/{series_id}/tags/{tag_id}": { + "/api/v1/series/{series_id}/external-links/{source}": { "delete": { "tags": [ - "Tags" + "Series" ], - "summary": "Remove a tag from a series", - "operationId": "remove_series_tag", + "summary": "Delete an external link by source name", + "operationId": "delete_external_link", "parameters": [ { "name": "series_id", @@ -7761,25 +7992,24 @@ } }, { - "name": "tag_id", + "name": "source", "in": "path", - "description": "Tag ID", + "description": "Source name (e.g., 'myanimelist', 'mangadex')", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], "responses": { "204": { - "description": "Tag removed from series" + "description": "External link deleted" }, "403": { - "description": "Forbidden" + "description": "Forbidden - admin only" }, "404": { - "description": "Series or tag link not found" + "description": "Series or link not found" } }, "security": [ @@ -7792,13 +8022,13 @@ ] } }, - "/api/v1/series/{series_id}/thumbnail": { + "/api/v1/series/{series_id}/external-ratings": { "get": { "tags": [ "Series" ], - "summary": "Get thumbnail/cover image for a series", - "operationId": "get_series_thumbnail", + "summary": "Get external ratings for a series", + "operationId": "get_series_external_ratings", "parameters": [ { "name": "series_id", @@ -7813,14 +8043,15 @@ ], "responses": { "200": { - "description": "Thumbnail image", + "description": "List of external ratings for the series", "content": { - "image/jpeg": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalRatingListResponse" + } + } } }, - "304": { - "description": "Not modified (client cache is valid)" - }, "403": { "description": "Forbidden" }, @@ -7836,16 +8067,13 @@ "api_key": [] } ] - } - }, - "/api/v1/series/{series_id}/thumbnails/generate": { + }, "post": { "tags": [ - "Thumbnails" + "Series" ], - "summary": "Generate thumbnails for all books in a series", - "description": "Queues a fan-out task that enqueues individual thumbnail generation tasks for each book in the series.\n\n# Permission Required\n- `tasks:write`", - "operationId": "generate_series_thumbnails", + "summary": "Add or update an external rating for a series", + "operationId": "create_external_rating", "parameters": [ { "name": "series_id", @@ -7862,7 +8090,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ForceRequest" + "$ref": "#/components/schemas/CreateExternalRatingRequest" } } }, @@ -7870,17 +8098,17 @@ }, "responses": { "200": { - "description": "Thumbnail generation task queued", + "description": "External rating created or updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTaskResponse" + "$ref": "#/components/schemas/ExternalRatingDto" } } } }, "403": { - "description": "Permission denied" + "description": "Forbidden - admin only" }, "404": { "description": "Series not found" @@ -7888,7 +8116,7 @@ }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -7896,13 +8124,13 @@ ] } }, - "/api/v1/series/{series_id}/unread": { - "post": { + "/api/v1/series/{series_id}/external-ratings/{source}": { + "delete": { "tags": [ "Series" ], - "summary": "Mark all books in a series as unread", - "operationId": "mark_series_as_unread", + "summary": "Delete an external rating by source name", + "operationId": "delete_external_rating", "parameters": [ { "name": "series_id", @@ -7913,24 +8141,26 @@ "type": "string", "format": "uuid" } + }, + { + "name": "source", + "in": "path", + "description": "Source name (e.g., 'myanimelist', 'anilist')", + "required": true, + "schema": { + "type": "string" + } } ], "responses": { - "200": { - "description": "Series marked as unread", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MarkReadResponse" - } - } - } + "204": { + "description": "External rating deleted" }, "403": { - "description": "Forbidden" + "description": "Forbidden - admin only" }, "404": { - "description": "Series not found" + "description": "Series or rating not found" } }, "security": [ @@ -7943,64 +8173,41 @@ ] } }, - "/api/v1/settings/branding": { + "/api/v1/series/{series_id}/genres": { "get": { "tags": [ - "Settings" + "Genres" ], - "summary": "Get branding settings (unauthenticated)", - "description": "Returns branding-related settings that are needed on unauthenticated pages\nlike the login screen. This endpoint does not require authentication.", - "operationId": "get_branding_settings", - "responses": { - "200": { - "description": "Branding settings", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BrandingSettingsDto" - }, - "example": { - "application_name": "Codex" - } - } + "summary": "Get genres for a series", + "operationId": "get_series_genres", + "parameters": [ + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" } } - } - } - }, - "/api/v1/settings/public": { - "get": { - "tags": [ - "Settings" ], - "summary": "Get public display settings (authenticated users)", - "description": "Returns non-sensitive settings that affect UI/display behavior.\nThis endpoint is available to all authenticated users, not just admins.", - "operationId": "get_public_settings", "responses": { "200": { - "description": "Public settings", + "description": "List of genres for the series", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/PublicSettingDto" - }, - "propertyNames": { - "type": "string" - } - }, - "example": { - "display.custom_metadata_template": { - "key": "display.custom_metadata_template", - "value": "{{#if custom_metadata}}## Additional Information\n{{#each custom_metadata}}- **{{@key}}**: {{this}}\n{{/each}}{{/if}}" - } + "$ref": "#/components/schemas/GenreListResponse" } } } }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Series not found" } }, "security": [ @@ -8011,21 +8218,30 @@ "api_key": [] } ] - } - }, - "/api/v1/setup/initialize": { - "post": { + }, + "put": { "tags": [ - "Setup" + "Genres" + ], + "summary": "Set genres for a series (replaces existing)", + "operationId": "set_series_genres", + "parameters": [ + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "summary": "Initialize application setup by creating the first admin user", - "description": "Creates the first admin user with email verification bypassed and returns a JWT token", - "operationId": "initialize_setup", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InitializeSetupRequest" + "$ref": "#/components/schemas/SetSeriesGenresRequest" } } }, @@ -8033,37 +8249,54 @@ }, "responses": { "200": { - "description": "Setup initialized", + "description": "Genres updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InitializeSetupResponse" + "$ref": "#/components/schemas/GenreListResponse" } } } }, - "400": { - "description": "Invalid request or setup already completed" + "403": { + "description": "Forbidden" }, - "422": { - "description": "Validation error" + "404": { + "description": "Series not found" } - } - } - }, - "/api/v1/setup/settings": { - "patch": { + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + }, + "post": { "tags": [ - "Setup" + "Genres" + ], + "summary": "Add a single genre to a series", + "operationId": "add_series_genre", + "parameters": [ + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "summary": "Configure initial settings (optional step in setup wizard)", - "description": "Allows the newly created admin to configure database settings", - "operationId": "configure_initial_settings", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ConfigureSettingsRequest" + "$ref": "#/components/schemas/AddSeriesGenreRequest" } } }, @@ -8071,92 +8304,70 @@ }, "responses": { "200": { - "description": "Settings configured", + "description": "Genre added", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ConfigureSettingsResponse" + "$ref": "#/components/schemas/GenreDto" } } } }, "403": { - "description": "Forbidden - Admin only" + "description": "Forbidden" + }, + "404": { + "description": "Series not found" } }, "security": [ { "jwt_bearer": [] + }, + { + "api_key": [] } ] } }, - "/api/v1/setup/status": { - "get": { - "tags": [ - "Setup" - ], - "summary": "Check if initial setup is required", - "description": "Returns whether the application needs initial setup (no users exist)", - "operationId": "setup_status", - "responses": { - "200": { - "description": "Setup status", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SetupStatusResponse" - } - } - } - } - } - } - }, - "/api/v1/tags": { - "get": { + "/api/v1/series/{series_id}/genres/{genre_id}": { + "delete": { "tags": [ - "Tags" + "Genres" ], - "summary": "List all tags", - "operationId": "list_tags", + "summary": "Remove a genre from a series", + "operationId": "remove_series_genre", "parameters": [ { - "name": "page", - "in": "query", - "description": "Page number (1-indexed, default 1)", - "required": false, + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string", + "format": "uuid" } }, { - "name": "pageSize", - "in": "query", - "description": "Number of items per page (default 50, max 500)", - "required": false, + "name": "genre_id", + "in": "path", + "description": "Genre ID", + "required": true, "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string", + "format": "uuid" } } ], "responses": { - "200": { - "description": "List of all tags", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaginatedResponse_TagDto" - } - } - } + "204": { + "description": "Genre removed from series" }, "403": { "description": "Forbidden" + }, + "404": { + "description": "Series or genre link not found" } }, "security": [ @@ -8169,50 +8380,19 @@ ] } }, - "/api/v1/tags/cleanup": { - "post": { + "/api/v1/series/{series_id}/metadata": { + "get": { "tags": [ - "Tags" + "Series" ], - "summary": "Delete all unused tags (tags with no series linked)", - "operationId": "cleanup_tags", - "responses": { - "200": { - "description": "Cleanup completed", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TaxonomyCleanupResponse" - } - } - } - }, - "403": { - "description": "Forbidden - admin only" - } - }, - "security": [ - { - "jwt_bearer": [] - }, - { - "api_key": [] - } - ] - } - }, - "/api/v1/tags/{tag_id}": { - "delete": { - "tags": [ - "Tags" - ], - "summary": "Delete a tag from the taxonomy (admin only)", - "operationId": "delete_tag", + "summary": "Get series metadata including all related data", + "description": "Returns comprehensive metadata with lock states, genres, tags, alternate titles,\nexternal ratings, and external links.", + "operationId": "get_series_metadata", "parameters": [ { - "name": "tag_id", + "name": "series_id", "in": "path", - "description": "Tag ID", + "description": "Series ID", "required": true, "schema": { "type": "string", @@ -8221,14 +8401,21 @@ } ], "responses": { - "204": { - "description": "Tag deleted" + "200": { + "description": "Series metadata with all related data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FullSeriesMetadataResponse" + } + } + } }, "403": { - "description": "Forbidden - admin only" + "description": "Forbidden" }, "404": { - "description": "Tag not found" + "description": "Series not found" } }, "security": [ @@ -8239,89 +8426,87 @@ "api_key": [] } ] - } - }, - "/api/v1/tasks": { - "get": { + }, + "put": { "tags": [ - "Task Queue" + "Series" ], - "summary": "List tasks with optional filtering", - "description": "# Permission Required\n- `tasks:read`", - "operationId": "list_tasks", + "summary": "Replace all series metadata (PUT)", + "description": "Replaces all metadata fields with the values in the request.\nOmitting a field (or setting it to null) will clear that field.", + "operationId": "replace_series_metadata", "parameters": [ { - "name": "status", - "in": "query", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "taskType", - "in": "query", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "limit", - "in": "query", - "required": false, + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string", + "format": "uuid" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceSeriesMetadataRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Tasks retrieved successfully", + "description": "Metadata replaced successfully", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TaskResponse" - } + "$ref": "#/components/schemas/SeriesMetadataResponse" } } } }, "403": { - "description": "Permission denied" + "description": "Forbidden" + }, + "404": { + "description": "Series not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] } ] }, - "post": { + "patch": { "tags": [ - "Task Queue" + "Series" + ], + "summary": "Partially update series metadata (PATCH)", + "description": "Only provided fields will be updated. Absent fields are unchanged.\nExplicitly null fields will be cleared.", + "operationId": "patch_series_metadata", + "parameters": [ + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "summary": "Create a new task", - "description": "# Permission Required\n- `tasks:write`", - "operationId": "create_task", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTaskRequest" + "$ref": "#/components/schemas/PatchSeriesMetadataRequest" } } }, @@ -8329,25 +8514,25 @@ }, "responses": { "200": { - "description": "Task created successfully", + "description": "Metadata updated successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTaskResponse" + "$ref": "#/components/schemas/SeriesMetadataResponse" } } } }, - "400": { - "description": "Invalid request" - }, "403": { - "description": "Permission denied" + "description": "Forbidden" + }, + "404": { + "description": "Series not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -8355,77 +8540,102 @@ ] } }, - "/api/v1/tasks/nuke": { - "delete": { + "/api/v1/series/{series_id}/metadata/locks": { + "get": { "tags": [ - "Task Queue" + "Series" + ], + "summary": "Get metadata lock states", + "operationId": "get_metadata_locks", + "parameters": [ + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "summary": "Nuclear option: Delete ALL tasks", - "description": "# Permission Required\n- `admin`", - "operationId": "nuke_all_tasks", "responses": { "200": { - "description": "All tasks deleted", + "description": "Current lock states", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PurgeTasksResponse" + "$ref": "#/components/schemas/MetadataLocks" } } } }, "403": { - "description": "Permission denied (admin only)" + "description": "Forbidden" + }, + "404": { + "description": "Series not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] } ] - } - }, - "/api/v1/tasks/purge": { - "delete": { + }, + "put": { "tags": [ - "Task Queue" + "Series" ], - "summary": "Purge old completed/failed tasks", - "description": "# Permission Required\n- `tasks:write`", - "operationId": "purge_old_tasks", + "summary": "Update metadata lock states", + "description": "Sets which metadata fields are locked. Locked fields will not be overwritten\nby automatic metadata refresh from book analysis or external sources.", + "operationId": "update_metadata_locks", "parameters": [ { - "name": "days", - "in": "query", - "description": "Delete tasks older than N days (default: 30)", - "required": false, + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, "schema": { - "type": "integer", - "format": "int64" + "type": "string", + "format": "uuid" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateMetadataLocksRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Tasks purged successfully", + "description": "Lock states updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PurgeTasksResponse" + "$ref": "#/components/schemas/MetadataLocks" } } } }, "403": { - "description": "Permission denied" + "description": "Forbidden" + }, + "404": { + "description": "Series not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -8433,59 +8643,43 @@ ] } }, - "/api/v1/tasks/stats": { - "get": { + "/api/v1/series/{series_id}/purge-deleted": { + "delete": { "tags": [ - "Task Queue" + "Series" ], - "summary": "Get queue statistics", - "description": "# Permission Required\n- `tasks:read`", - "operationId": "get_task_stats", - "responses": { - "200": { - "description": "Statistics retrieved successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TaskStats" - } - } - } - }, - "403": { - "description": "Permission denied" - } - }, - "security": [ - { - "bearer_auth": [] - }, + "summary": "Purge deleted books from a series", + "operationId": "purge_series_deleted_books", + "parameters": [ { - "api_key": [] + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } } - ] - } - }, - "/api/v1/tasks/stream": { - "get": { - "tags": [ - "Events" ], - "summary": "Subscribe to real-time task progress events via SSE", - "description": "Clients can subscribe to this endpoint to receive real-time notifications\nabout background task progress (analyze_book, generate_thumbnails, etc.).\n\n## Authentication\nRequires valid authentication with `LibrariesRead` permission.\n\n## Event Format\nEvents are sent as JSON-encoded `TaskProgressEvent` objects with the following structure:\n```json\n{\n \"task_id\": \"uuid\",\n \"task_type\": \"analyze_book\",\n \"status\": \"running\",\n \"progress\": {\n \"current\": 5,\n \"total\": 10,\n \"message\": \"Processing book 5 of 10\"\n },\n \"started_at\": \"2024-01-06T12:00:00Z\",\n \"library_id\": \"uuid\"\n}\n```\n\n## Keep-Alive\nA keep-alive message is sent every 15 seconds to prevent connection timeout.", - "operationId": "task_progress_stream", "responses": { "200": { - "description": "SSE stream of task progress events", + "description": "Number of books purged", "content": { - "text/event-stream": {} + "text/plain": { + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } } }, - "401": { - "description": "Unauthorized" - }, "403": { "description": "Forbidden" + }, + "404": { + "description": "Series not found" } }, "security": [ @@ -8498,19 +8692,19 @@ ] } }, - "/api/v1/tasks/{task_id}": { + "/api/v1/series/{series_id}/rating": { "get": { "tags": [ - "Task Queue" + "Ratings" ], - "summary": "Get task by ID", - "description": "# Permission Required\n- `tasks:read`", - "operationId": "get_task", + "summary": "Get the current user's rating for a series", + "description": "Returns null if no rating exists (not a 404, since the series exists but has no rating)", + "operationId": "get_series_rating", "parameters": [ { - "name": "task_id", + "name": "series_id", "in": "path", - "description": "Task ID", + "description": "Series ID", "required": true, "schema": { "type": "string", @@ -8520,45 +8714,49 @@ ], "responses": { "200": { - "description": "Task retrieved successfully", + "description": "User's rating for the series (null if not rated)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TaskResponse" + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/UserSeriesRatingDto" + } + ] } } } }, "403": { - "description": "Permission denied" + "description": "Forbidden" }, "404": { - "description": "Task not found" + "description": "Series not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] } ] - } - }, - "/api/v1/tasks/{task_id}/cancel": { - "post": { + }, + "put": { "tags": [ - "Task Queue" + "Ratings" ], - "summary": "Cancel a task", - "description": "# Permission Required\n- `tasks:write`", - "operationId": "cancel_task", + "summary": "Set (create or update) the current user's rating for a series", + "operationId": "set_series_rating", "parameters": [ { - "name": "task_id", + "name": "series_id", "in": "path", - "description": "Task ID", + "description": "Series ID", "required": true, "schema": { "type": "string", @@ -8566,50 +8764,57 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetUserRatingRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Task cancelled successfully", + "description": "Rating saved", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/UserSeriesRatingDto" } } } }, "400": { - "description": "Task cannot be cancelled" + "description": "Invalid rating value" }, "403": { - "description": "Permission denied" + "description": "Forbidden" }, "404": { - "description": "Task not found" + "description": "Series not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] } ] - } - }, - "/api/v1/tasks/{task_id}/retry": { - "post": { + }, + "delete": { "tags": [ - "Task Queue" + "Ratings" ], - "summary": "Retry a failed task", - "description": "# Permission Required\n- `tasks:write`", - "operationId": "retry_task", + "summary": "Delete the current user's rating for a series", + "operationId": "delete_series_rating", "parameters": [ { - "name": "task_id", + "name": "series_id", "in": "path", - "description": "Task ID", + "description": "Series ID", "required": true, "schema": { "type": "string", @@ -8618,29 +8823,19 @@ } ], "responses": { - "200": { - "description": "Task queued for retry", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MessageResponse" - } - } - } - }, - "400": { - "description": "Task is not in failed state" + "204": { + "description": "Rating deleted" }, "403": { - "description": "Permission denied" + "description": "Forbidden" }, "404": { - "description": "Task not found" + "description": "Series or rating not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -8648,19 +8843,19 @@ ] } }, - "/api/v1/tasks/{task_id}/unlock": { - "post": { + "/api/v1/series/{series_id}/ratings/average": { + "get": { "tags": [ - "Task Queue" + "Series" ], - "summary": "Unlock a stuck task", - "description": "# Permission Required\n- `tasks:write`", - "operationId": "unlock_task", + "summary": "Get the average community rating for a series", + "description": "Returns the average rating from all users and the total count of ratings.\nRatings are stored on a 0-100 scale internally.", + "operationId": "get_series_average_rating", "parameters": [ { - "name": "task_id", + "name": "series_id", "in": "path", - "description": "Task ID", + "description": "Series ID", "required": true, "schema": { "type": "string", @@ -8670,25 +8865,29 @@ ], "responses": { "200": { - "description": "Task unlocked successfully", + "description": "Average rating for the series", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/SeriesAverageRatingResponse" + }, + "example": { + "average": 78.5, + "count": 15 } } } }, "403": { - "description": "Permission denied" + "description": "Forbidden" }, "404": { - "description": "Task not found" + "description": "Series not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -8696,42 +8895,46 @@ ] } }, - "/api/v1/thumbnails/generate": { + "/api/v1/series/{series_id}/read": { "post": { "tags": [ - "Thumbnails" + "Series" ], - "summary": "Generate thumbnails for books in a scope", - "description": "This queues a fan-out task that enqueues individual thumbnail generation tasks for each book.\n\n**Scope priority:**\n1. If `series_id` is provided, only books in that series\n2. If `library_id` is provided, only books in that library\n3. If neither is provided, all books in all libraries\n\n**Force behavior:**\n- `force: false` (default): Only generates thumbnails for books that don't have one\n- `force: true`: Regenerates all thumbnails, replacing existing ones\n\n# Permission Required\n- `tasks:write`", - "operationId": "generate_thumbnails", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GenerateThumbnailsRequest" - } + "summary": "Mark all books in a series as read", + "operationId": "mark_series_as_read", + "parameters": [ + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" } - }, - "required": true - }, + } + ], "responses": { "200": { - "description": "Thumbnail generation task queued", + "description": "Series marked as read", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTaskResponse" + "$ref": "#/components/schemas/MarkReadResponse" } } } }, "403": { - "description": "Permission denied" + "description": "Forbidden" + }, + "404": { + "description": "Series not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -8739,31 +8942,46 @@ ] } }, - "/api/v1/user/preferences": { + "/api/v1/series/{series_id}/sharing-tags": { "get": { "tags": [ - "User Preferences" + "Sharing Tags" + ], + "summary": "Get sharing tags for a series (admin only)", + "operationId": "get_series_sharing_tags", + "parameters": [ + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "summary": "Get all preferences for the authenticated user", - "operationId": "get_all_preferences", "responses": { "200": { - "description": "User preferences retrieved", + "description": "List of sharing tags for the series", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserPreferencesResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/SharingTagSummaryDto" + } } } } }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden - Missing permission" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -8772,15 +8990,27 @@ }, "put": { "tags": [ - "User Preferences" + "Sharing Tags" + ], + "summary": "Set sharing tags for a series (replaces existing) (admin only)", + "operationId": "set_series_sharing_tags", + "parameters": [ + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "summary": "Set multiple preferences at once", - "operationId": "set_bulk_preferences", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BulkSetPreferencesRequest" + "$ref": "#/components/schemas/SetSeriesSharingTagsRequest" } } }, @@ -8788,25 +9018,73 @@ }, "responses": { "200": { - "description": "Preferences updated successfully", + "description": "Sharing tags set", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetPreferencesResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/SharingTagSummaryDto" + } } } } }, + "403": { + "description": "Forbidden - Missing permission" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + }, + "post": { + "tags": [ + "Sharing Tags" + ], + "summary": "Add a sharing tag to a series (admin only)", + "operationId": "add_series_sharing_tag", + "parameters": [ + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModifySeriesSharingTagRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Sharing tag added" + }, "400": { - "description": "Invalid preference key or value" + "description": "Tag already assigned" }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden - Missing permission" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -8814,45 +9092,96 @@ ] } }, - "/api/v1/user/preferences/{key}": { + "/api/v1/series/{series_id}/sharing-tags/{tag_id}": { + "delete": { + "tags": [ + "Sharing Tags" + ], + "summary": "Remove a sharing tag from a series (admin only)", + "operationId": "remove_series_sharing_tag", + "parameters": [ + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "tag_id", + "in": "path", + "description": "Sharing tag ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Sharing tag removed" + }, + "403": { + "description": "Forbidden - Missing permission" + }, + "404": { + "description": "Sharing tag not assigned to series" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/series/{series_id}/tags": { "get": { "tags": [ - "User Preferences" + "Tags" ], - "summary": "Get a single preference by key", - "operationId": "get_preference", + "summary": "Get tags for a series", + "operationId": "get_series_tags", "parameters": [ { - "name": "key", + "name": "series_id", "in": "path", - "description": "Preference key (e.g., 'ui.theme')", + "description": "Series ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], "responses": { "200": { - "description": "Preference retrieved", + "description": "List of tags for the series", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserPreferenceDto" + "$ref": "#/components/schemas/TagListResponse" } } } }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden" }, "404": { - "description": "Preference not found" + "description": "Series not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] @@ -8861,18 +9190,19 @@ }, "put": { "tags": [ - "User Preferences" + "Tags" ], - "summary": "Set a single preference value", - "operationId": "set_preference", + "summary": "Set tags for a series (replaces existing)", + "operationId": "set_series_tags", "parameters": [ { - "name": "key", + "name": "series_id", "in": "path", - "description": "Preference key (e.g., 'ui.theme')", + "description": "Series ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -8880,7 +9210,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetPreferenceRequest" + "$ref": "#/components/schemas/SetSeriesTagsRequest" } } }, @@ -8888,93 +9218,75 @@ }, "responses": { "200": { - "description": "Preference set successfully", + "description": "Tags updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserPreferenceDto" + "$ref": "#/components/schemas/TagListResponse" } } } }, - "400": { - "description": "Invalid preference value" + "403": { + "description": "Forbidden" }, - "401": { - "description": "Unauthorized" + "404": { + "description": "Series not found" } }, "security": [ { - "bearer_auth": [] + "jwt_bearer": [] }, { "api_key": [] } ] }, - "delete": { + "post": { "tags": [ - "User Preferences" + "Tags" ], - "summary": "Delete (reset) a preference to its default", - "operationId": "delete_preference", + "summary": "Add a single tag to a series", + "operationId": "add_series_tag", "parameters": [ { - "name": "key", + "name": "series_id", "in": "path", - "description": "Preference key to delete", + "description": "Series ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], - "responses": { - "200": { - "description": "Preference deleted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeletePreferenceResponse" - } + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddSeriesTagRequest" } } }, - "401": { - "description": "Unauthorized" - } + "required": true }, - "security": [ - { - "bearer_auth": [] - }, - { - "api_key": [] - } - ] - } - }, - "/api/v1/user/ratings": { - "get": { - "tags": [ - "Ratings" - ], - "summary": "List all of the current user's ratings", - "operationId": "list_user_ratings", "responses": { "200": { - "description": "List of user's ratings", + "description": "Tag added", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserRatingsListResponse" + "$ref": "#/components/schemas/TagDto" } } } }, "403": { "description": "Forbidden" + }, + "404": { + "description": "Series not found" } }, "security": [ @@ -8987,162 +9299,44 @@ ] } }, - "/api/v1/user/sharing-tags": { - "get": { + "/api/v1/series/{series_id}/tags/{tag_id}": { + "delete": { "tags": [ - "Sharing Tags" + "Tags" ], - "summary": "Get current user's sharing tag grants", - "operationId": "get_my_sharing_tags", - "responses": { - "200": { - "description": "List of sharing tag grants for the current user", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserSharingTagGrantsResponse" - } - } - } - } - }, - "security": [ - { - "jwt_bearer": [] - }, - { - "api_key": [] - } - ] - } - }, - "/api/v1/users": { - "get": { - "tags": [ - "Users" - ], - "summary": "List all users (admin only) with pagination and filtering", - "operationId": "list_users", + "summary": "Remove a tag from a series", + "operationId": "remove_series_tag", "parameters": [ { - "name": "role", - "in": "query", - "description": "Filter by role", - "required": false, - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/UserRole" - } - ] - } - }, - { - "name": "sharingTag", - "in": "query", - "description": "Filter by sharing tag name (users who have a grant for this tag)", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "sharingTagMode", - "in": "query", - "description": "Filter by sharing tag access mode (allow/deny) - only used with sharing_tag", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "page", - "in": "query", - "description": "Page number (1-indexed, default 1)", - "required": false, + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string", + "format": "uuid" } }, { - "name": "pageSize", - "in": "query", - "description": "Number of items per page (max 100, default 50)", - "required": false, + "name": "tag_id", + "in": "path", + "description": "Tag ID", + "required": true, "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string", + "format": "uuid" } } ], "responses": { - "200": { - "description": "Paginated list of users", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaginatedResponse_UserDto" - } - } - } + "204": { + "description": "Tag removed from series" }, "403": { - "description": "Forbidden - Admin only" - } - }, - "security": [ - { - "jwt_bearer": [] - }, - { - "api_key": [] - } - ] - }, - "post": { - "tags": [ - "Users" - ], - "summary": "Create a new user (admin only)", - "operationId": "create_user", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateUserRequest" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "User created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserDto" - } - } - } - }, - "400": { - "description": "Invalid request" + "description": "Forbidden" }, - "403": { - "description": "Forbidden - Admin only" + "404": { + "description": "Series or tag link not found" } }, "security": [ @@ -9155,18 +9349,18 @@ ] } }, - "/api/v1/users/{user_id}": { + "/api/v1/series/{series_id}/thumbnail": { "get": { "tags": [ - "Users" + "Series" ], - "summary": "Get user by ID (admin only)", - "operationId": "get_user", + "summary": "Get thumbnail/cover image for a series", + "operationId": "get_series_thumbnail", "parameters": [ { - "name": "user_id", + "name": "series_id", "in": "path", - "description": "User ID", + "description": "Series ID", "required": true, "schema": { "type": "string", @@ -9176,58 +9370,19 @@ ], "responses": { "200": { - "description": "User details with sharing tags", + "description": "Thumbnail image", "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserDetailDto" - } - } + "image/jpeg": {} } }, - "403": { - "description": "Forbidden - Admin only" - }, - "404": { - "description": "User not found" - } - }, - "security": [ - { - "jwt_bearer": [] - }, - { - "api_key": [] - } - ] - }, - "delete": { - "tags": [ - "Users" - ], - "summary": "Delete a user (admin only)", - "operationId": "delete_user", - "parameters": [ - { - "name": "user_id", - "in": "path", - "description": "User ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "204": { - "description": "User deleted" + "304": { + "description": "Not modified (client cache is valid)" }, "403": { - "description": "Forbidden - Admin only" + "description": "Forbidden" }, "404": { - "description": "User not found" + "description": "Series not found" } }, "security": [ @@ -9238,18 +9393,21 @@ "api_key": [] } ] - }, - "patch": { + } + }, + "/api/v1/series/{series_id}/thumbnail/generate": { + "post": { "tags": [ - "Users" + "Thumbnails" ], - "summary": "Update a user (admin only, partial update)", - "operationId": "update_user", + "summary": "Generate thumbnail for a series", + "description": "Queues a task to generate (or regenerate) the thumbnail for a specific series.\nThe series thumbnail is derived from the first book's cover.\n\n# Permission Required\n- `tasks:write`", + "operationId": "generate_series_thumbnail", "parameters": [ { - "name": "user_id", + "name": "series_id", "in": "path", - "description": "User ID", + "description": "Series ID", "required": true, "schema": { "type": "string", @@ -9261,7 +9419,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateUserRequest" + "$ref": "#/components/schemas/ForceRequest" } } }, @@ -9269,25 +9427,25 @@ }, "responses": { "200": { - "description": "User updated", + "description": "Thumbnail generation task queued", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserDto" + "$ref": "#/components/schemas/CreateTaskResponse" } } } }, "403": { - "description": "Forbidden - Admin only" + "description": "Permission denied" }, "404": { - "description": "User not found" + "description": "Series not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -9295,18 +9453,18 @@ ] } }, - "/api/v1/users/{user_id}/sharing-tags": { - "get": { + "/api/v1/series/{series_id}/unread": { + "post": { "tags": [ - "Sharing Tags" + "Series" ], - "summary": "Get sharing tag grants for a user (admin only)", - "operationId": "get_user_sharing_tags", + "summary": "Mark all books in a series as unread", + "operationId": "mark_series_as_unread", "parameters": [ { - "name": "user_id", + "name": "series_id", "in": "path", - "description": "User ID", + "description": "Series ID", "required": true, "schema": { "type": "string", @@ -9316,17 +9474,20 @@ ], "responses": { "200": { - "description": "List of sharing tag grants for the user", + "description": "Series marked as unread", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserSharingTagGrantsResponse" + "$ref": "#/components/schemas/MarkReadResponse" } } } }, "403": { - "description": "Forbidden - Missing permission" + "description": "Forbidden" + }, + "404": { + "description": "Series not found" } }, "security": [ @@ -9337,30 +9498,129 @@ "api_key": [] } ] - }, - "put": { + } + }, + "/api/v1/settings/branding": { + "get": { "tags": [ - "Sharing Tags" + "Settings" ], - "summary": "Set a user's sharing tag grant (admin only)", - "operationId": "set_user_sharing_tag", - "parameters": [ + "summary": "Get branding settings (unauthenticated)", + "description": "Returns branding-related settings that are needed on unauthenticated pages\nlike the login screen. This endpoint does not require authentication.", + "operationId": "get_branding_settings", + "responses": { + "200": { + "description": "Branding settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrandingSettingsDto" + }, + "example": { + "application_name": "Codex" + } + } + } + } + } + } + }, + "/api/v1/settings/public": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Get public display settings (authenticated users)", + "description": "Returns non-sensitive settings that affect UI/display behavior.\nThis endpoint is available to all authenticated users, not just admins.", + "operationId": "get_public_settings", + "responses": { + "200": { + "description": "Public settings", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/PublicSettingDto" + }, + "propertyNames": { + "type": "string" + } + }, + "example": { + "display.custom_metadata_template": { + "key": "display.custom_metadata_template", + "value": "{{#if custom_metadata}}## Additional Information\n{{#each custom_metadata}}- **{{@key}}**: {{this}}\n{{/each}}{{/if}}" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ { - "name": "user_id", - "in": "path", - "description": "User ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/setup/initialize": { + "post": { + "tags": [ + "Setup" + ], + "summary": "Initialize application setup by creating the first admin user", + "description": "Creates the first admin user with email verification bypassed and returns a JWT token", + "operationId": "initialize_setup", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InitializeSetupRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Setup initialized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InitializeSetupResponse" + } + } } + }, + "400": { + "description": "Invalid request or setup already completed" + }, + "422": { + "description": "Validation error" } + } + } + }, + "/api/v1/setup/settings": { + "patch": { + "tags": [ + "Setup" ], + "summary": "Configure initial settings (optional step in setup wizard)", + "description": "Allows the newly created admin to configure database settings", + "operationId": "configure_initial_settings", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetUserSharingTagGrantRequest" + "$ref": "#/components/schemas/ConfigureSettingsRequest" } } }, @@ -9368,70 +9628,92 @@ }, "responses": { "200": { - "description": "Sharing tag grant set", + "description": "Settings configured", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserSharingTagGrantDto" + "$ref": "#/components/schemas/ConfigureSettingsResponse" } } } }, "403": { - "description": "Forbidden - Missing permission" - }, - "404": { - "description": "Sharing tag not found" + "description": "Forbidden - Admin only" } }, "security": [ { "jwt_bearer": [] - }, - { - "api_key": [] } ] } }, - "/api/v1/users/{user_id}/sharing-tags/{tag_id}": { - "delete": { + "/api/v1/setup/status": { + "get": { "tags": [ - "Sharing Tags" + "Setup" ], - "summary": "Remove a user's sharing tag grant (admin only)", - "operationId": "remove_user_sharing_tag", + "summary": "Check if initial setup is required", + "description": "Returns whether the application needs initial setup (no users exist)", + "operationId": "setup_status", + "responses": { + "200": { + "description": "Setup status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetupStatusResponse" + } + } + } + } + } + } + }, + "/api/v1/tags": { + "get": { + "tags": [ + "Tags" + ], + "summary": "List all tags", + "operationId": "list_tags", "parameters": [ { - "name": "user_id", - "in": "path", - "description": "User ID", - "required": true, + "name": "page", + "in": "query", + "description": "Page number (1-indexed, default 1)", + "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "format": "int64", + "minimum": 0 } }, { - "name": "tag_id", - "in": "path", - "description": "Sharing tag ID", - "required": true, + "name": "pageSize", + "in": "query", + "description": "Number of items per page (default 50, max 500)", + "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "format": "int64", + "minimum": 0 } } ], "responses": { - "204": { - "description": "Sharing tag grant removed" + "200": { + "description": "List of all tags", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse_TagDto" + } + } + } }, "403": { - "description": "Forbidden - Missing permission" - }, - "404": { - "description": "Grant not found" + "description": "Forbidden" } }, "security": [ @@ -9444,41 +9726,26 @@ ] } }, - "/health": { - "get": { - "tags": [ - "Health" - ], - "summary": "Health check endpoint - checks database connectivity", - "description": "Returns \"OK\" with 200 status if database is healthy,\nor \"Service Unavailable\" with 503 status if database check fails.", - "operationId": "health_check", - "responses": { - "200": { - "description": "Service is healthy" - }, - "503": { - "description": "Service is unavailable" - } - } - } - }, - "/opds": { - "get": { + "/api/v1/tags/cleanup": { + "post": { "tags": [ - "OPDS" + "Tags" ], - "summary": "Root OPDS catalog", - "description": "Returns the main navigation feed with links to:\n- All libraries\n- Search\n- Recent additions", - "operationId": "root_catalog", + "summary": "Delete all unused tags (tags with no series linked)", + "operationId": "cleanup_tags", "responses": { "200": { - "description": "OPDS root catalog", + "description": "Cleanup completed", "content": { - "application/atom+xml": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaxonomyCleanupResponse" + } + } } }, "403": { - "description": "Forbidden" + "description": "Forbidden - admin only" } }, "security": [ @@ -9491,19 +9758,18 @@ ] } }, - "/opds/books/{book_id}/pages": { - "get": { + "/api/v1/tags/{tag_id}": { + "delete": { "tags": [ - "OPDS" + "Tags" ], - "summary": "OPDS-PSE: List all pages in a book", - "description": "Returns a PSE page feed with individual page links for streaming.\nThis allows OPDS clients to read books page-by-page without downloading the entire file.", - "operationId": "opds_book_pages", + "summary": "Delete a tag from the taxonomy (admin only)", + "operationId": "delete_tag", "parameters": [ { - "name": "book_id", + "name": "tag_id", "in": "path", - "description": "Book ID", + "description": "Tag ID", "required": true, "schema": { "type": "string", @@ -9512,46 +9778,14 @@ } ], "responses": { - "200": { - "description": "OPDS-PSE page feed", - "content": { - "application/atom+xml": {} - } + "204": { + "description": "Tag deleted" }, "403": { - "description": "Forbidden" + "description": "Forbidden - admin only" }, "404": { - "description": "Book not found" - } - }, - "security": [ - { - "jwt_bearer": [] - }, - { - "api_key": [] - } - ] - } - }, - "/opds/libraries": { - "get": { - "tags": [ - "OPDS" - ], - "summary": "List all libraries", - "description": "Returns a navigation feed with all available libraries", - "operationId": "opds_list_libraries", - "responses": { - "200": { - "description": "OPDS libraries feed", - "content": { - "application/atom+xml": {} - } - }, - "403": { - "description": "Forbidden" + "description": "Tag not found" } }, "security": [ @@ -9564,103 +9798,113 @@ ] } }, - "/opds/libraries/{library_id}": { + "/api/v1/tasks": { "get": { "tags": [ - "OPDS" + "Task Queue" ], - "summary": "List series in a library", - "description": "Returns an acquisition feed with all series in the specified library", - "operationId": "opds_library_series", + "summary": "List tasks with optional filtering", + "description": "# Permission Required\n- `tasks:read`", + "operationId": "list_tasks", "parameters": [ { - "name": "library_id", - "in": "path", - "description": "Library ID", - "required": true, + "name": "status", + "in": "query", + "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": [ + "string", + "null" + ] } }, { - "name": "page", + "name": "taskType", "in": "query", "required": false, "schema": { - "type": "integer", - "format": "int32", - "minimum": 0 + "type": [ + "string", + "null" + ] } }, { - "name": "pageSize", + "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", - "format": "int32", + "format": "int64", "minimum": 0 } } ], "responses": { "200": { - "description": "OPDS library series feed", + "description": "Tasks retrieved successfully", "content": { - "application/atom+xml": {} + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskResponse" + } + } + } } }, "403": { - "description": "Forbidden" - }, - "404": { - "description": "Library not found" + "description": "Permission denied" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - } - }, - "/opds/search": { - "get": { + }, + "post": { "tags": [ - "OPDS" + "Task Queue" ], - "summary": "OPDS search endpoint", - "description": "Searches books and series by title and returns an OPDS acquisition feed", - "operationId": "opds_search", - "parameters": [ - { - "name": "q", - "in": "query", - "description": "Search query string", - "required": true, - "schema": { - "type": "string" + "summary": "Create a new task", + "description": "# Permission Required\n- `tasks:write`", + "operationId": "create_task", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTaskRequest" + } } - } - ], + }, + "required": true + }, "responses": { "200": { - "description": "OPDS search results", + "description": "Task created successfully", "content": { - "application/atom+xml": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTaskResponse" + } + } } }, + "400": { + "description": "Invalid request" + }, "403": { - "description": "Forbidden" + "description": "Permission denied" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -9668,25 +9912,32 @@ ] } }, - "/opds/search.xml": { - "get": { + "/api/v1/tasks/nuke": { + "delete": { "tags": [ - "OPDS" + "Task Queue" ], - "summary": "OpenSearch descriptor endpoint", - "description": "Returns the OpenSearch XML descriptor for OPDS clients", - "operationId": "opds_opensearch_descriptor", + "summary": "Nuclear option: Delete ALL tasks", + "description": "# Permission Required\n- `admin`", + "operationId": "nuke_all_tasks", "responses": { "200": { - "description": "OpenSearch descriptor", + "description": "All tasks deleted", "content": { - "application/opensearchdescription+xml": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/PurgeTasksResponse" + } + } } + }, + "403": { + "description": "Permission denied (admin only)" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -9694,43 +9945,44 @@ ] } }, - "/opds/series/{series_id}": { - "get": { + "/api/v1/tasks/purge": { + "delete": { "tags": [ - "OPDS" + "Task Queue" ], - "summary": "List books in a series", - "description": "Returns an acquisition feed with all books in the specified series", - "operationId": "opds_series_books", + "summary": "Purge old completed/failed tasks", + "description": "# Permission Required\n- `tasks:write`", + "operationId": "purge_old_tasks", "parameters": [ { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, + "name": "days", + "in": "query", + "description": "Delete tasks older than N days (default: 30)", + "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "format": "int64" } } ], "responses": { "200": { - "description": "OPDS series books feed", + "description": "Tasks purged successfully", "content": { - "application/atom+xml": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/PurgeTasksResponse" + } + } } }, "403": { - "description": "Forbidden" - }, - "404": { - "description": "Series not found" + "description": "Permission denied" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -9738,32 +9990,32 @@ ] } }, - "/opds/v2": { + "/api/v1/tasks/stats": { "get": { "tags": [ - "OPDS 2.0" + "Task Queue" ], - "summary": "Root OPDS 2.0 catalog", - "description": "Returns the main navigation feed with links to:\n- All libraries\n- Search\n- Recent additions", - "operationId": "opds2_root", + "summary": "Get queue statistics", + "description": "# Permission Required\n- `tasks:read`", + "operationId": "get_task_stats", "responses": { "200": { - "description": "OPDS 2.0 root catalog", + "description": "Statistics retrieved successfully", "content": { - "application/opds+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Opds2Feed" + "$ref": "#/components/schemas/TaskStats" } } } }, "403": { - "description": "Forbidden" + "description": "Permission denied" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -9771,27 +10023,26 @@ ] } }, - "/opds/v2/libraries": { + "/api/v1/tasks/stream": { "get": { "tags": [ - "OPDS 2.0" + "Events" ], - "summary": "List all libraries (OPDS 2.0)", - "description": "Returns a navigation feed with all available libraries", - "operationId": "opds2_libraries", + "summary": "Subscribe to real-time task progress events via SSE", + "description": "Clients can subscribe to this endpoint to receive real-time notifications\nabout background task progress (analyze_book, generate_thumbnails, etc.).\n\n## Authentication\nRequires valid authentication with `LibrariesRead` permission.\n\n## Event Format\nEvents are sent as JSON-encoded `TaskProgressEvent` objects with the following structure:\n```json\n{\n \"task_id\": \"uuid\",\n \"task_type\": \"analyze_book\",\n \"status\": \"running\",\n \"progress\": {\n \"current\": 5,\n \"total\": 10,\n \"message\": \"Processing book 5 of 10\"\n },\n \"started_at\": \"2024-01-06T12:00:00Z\",\n \"library_id\": \"uuid\"\n}\n```\n\n## Keep-Alive\nA keep-alive message is sent every 15 seconds to prevent connection timeout.", + "operationId": "task_progress_stream", "responses": { "200": { - "description": "OPDS 2.0 libraries feed", + "description": "SSE stream of task progress events", "content": { - "application/opds+json": { - "schema": { - "$ref": "#/components/schemas/Opds2Feed" - } - } + "text/event-stream": {} } }, - "403": { - "description": "Forbidden" + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" } }, "security": [ @@ -9804,67 +10055,47 @@ ] } }, - "/opds/v2/libraries/{library_id}": { + "/api/v1/tasks/{task_id}": { "get": { "tags": [ - "OPDS 2.0" + "Task Queue" ], - "summary": "List series in a library (OPDS 2.0)", - "description": "Returns a navigation feed with all series in the specified library", - "operationId": "opds2_library_series", + "summary": "Get task by ID", + "description": "# Permission Required\n- `tasks:read`", + "operationId": "get_task", "parameters": [ { - "name": "library_id", + "name": "task_id", "in": "path", - "description": "Library ID", + "description": "Task ID", "required": true, "schema": { "type": "string", "format": "uuid" } - }, - { - "name": "page", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "format": "int32", - "minimum": 0 - } - }, - { - "name": "pageSize", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "format": "int32", - "minimum": 0 - } } ], "responses": { "200": { - "description": "OPDS 2.0 library series feed", + "description": "Task retrieved successfully", "content": { - "application/opds+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Opds2Feed" + "$ref": "#/components/schemas/TaskResponse" } } } }, "403": { - "description": "Forbidden" + "description": "Permission denied" }, "404": { - "description": "Library not found" + "description": "Task not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -9872,54 +10103,50 @@ ] } }, - "/opds/v2/recent": { - "get": { + "/api/v1/tasks/{task_id}/cancel": { + "post": { "tags": [ - "OPDS 2.0" + "Task Queue" ], - "summary": "List recent additions (OPDS 2.0)", - "description": "Returns a publications feed with recently added books", - "operationId": "opds2_recent", + "summary": "Cancel a task", + "description": "# Permission Required\n- `tasks:write`", + "operationId": "cancel_task", "parameters": [ { - "name": "page", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "format": "int32", - "minimum": 0 - } - }, - { - "name": "pageSize", - "in": "query", - "required": false, + "name": "task_id", + "in": "path", + "description": "Task ID", + "required": true, "schema": { - "type": "integer", - "format": "int32", - "minimum": 0 + "type": "string", + "format": "uuid" } } ], "responses": { "200": { - "description": "OPDS 2.0 recent additions feed", + "description": "Task cancelled successfully", "content": { - "application/opds+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Opds2Feed" + "$ref": "#/components/schemas/MessageResponse" } } } }, + "400": { + "description": "Task cannot be cancelled" + }, "403": { - "description": "Forbidden" + "description": "Permission denied" + }, + "404": { + "description": "Task not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -9927,46 +10154,50 @@ ] } }, - "/opds/v2/search": { - "get": { + "/api/v1/tasks/{task_id}/retry": { + "post": { "tags": [ - "OPDS 2.0" + "Task Queue" ], - "summary": "OPDS 2.0 search endpoint", - "description": "Searches books and series by title and returns an OPDS 2.0 publications feed", - "operationId": "opds2_search", + "summary": "Retry a failed task", + "description": "# Permission Required\n- `tasks:write`", + "operationId": "retry_task", "parameters": [ { - "name": "query", - "in": "query", - "description": "Search query string", + "name": "task_id", + "in": "path", + "description": "Task ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], "responses": { "200": { - "description": "OPDS 2.0 search results", + "description": "Task queued for retry", "content": { - "application/opds+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Opds2Feed" + "$ref": "#/components/schemas/MessageResponse" } } } }, "400": { - "description": "Bad request - empty query" + "description": "Task is not in failed state" }, "403": { - "description": "Forbidden" + "description": "Permission denied" + }, + "404": { + "description": "Task not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -9974,19 +10205,19 @@ ] } }, - "/opds/v2/series/{series_id}": { - "get": { + "/api/v1/tasks/{task_id}/unlock": { + "post": { "tags": [ - "OPDS 2.0" + "Task Queue" ], - "summary": "List books in a series (OPDS 2.0)", - "description": "Returns a publications feed with all books in the specified series", - "operationId": "opds2_series_books", + "summary": "Unlock a stuck task", + "description": "# Permission Required\n- `tasks:write`", + "operationId": "unlock_task", "parameters": [ { - "name": "series_id", + "name": "task_id", "in": "path", - "description": "Series ID", + "description": "Task ID", "required": true, "schema": { "type": "string", @@ -9996,25 +10227,25 @@ ], "responses": { "200": { - "description": "OPDS 2.0 series books feed", + "description": "Task unlocked successfully", "content": { - "application/opds+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Opds2Feed" + "$ref": "#/components/schemas/MessageResponse" } } } }, "403": { - "description": "Forbidden" + "description": "Permission denied" }, "404": { - "description": "Series not found" + "description": "Task not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -10022,62 +10253,48 @@ ] } }, - "/{prefix}/api/v1/books/list": { - "post": { + "/api/v1/user/preferences": { + "get": { "tags": [ - "Komga" + "User Preferences" ], - "summary": "Search/filter books", - "description": "Returns books matching the filter criteria.\nThis uses POST to support complex filter bodies.\n\n## Endpoint\n`POST /{prefix}/api/v1/books/list`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n- `sort` - Sort parameter (e.g., \"createdDate,desc\")\n\n## Request Body\nJSON object with filter criteria (library_id, series_id, search_term, etc.)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_search_books", - "parameters": [ - { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "page", - "in": "query", - "description": "Page number (0-indexed, Komga-style)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" + "summary": "Get all preferences for the authenticated user", + "operationId": "get_all_preferences", + "responses": { + "200": { + "description": "User preferences retrieved", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPreferencesResponse" + } + } } }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ { - "name": "size", - "in": "query", - "description": "Page size (default: 20)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" - } + "bearer_auth": [] }, { - "name": "sort", - "in": "query", - "description": "Sort parameter (e.g., \"createdDate,desc\", \"metadata.numberSort,asc\")", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } + "api_key": [] } + ] + }, + "put": { + "tags": [ + "User Preferences" ], + "summary": "Set multiple preferences at once", + "operationId": "set_bulk_preferences", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KomgaBooksSearchRequestDto" + "$ref": "#/components/schemas/BulkSetPreferencesRequest" } } }, @@ -10085,22 +10302,25 @@ }, "responses": { "200": { - "description": "Paginated list of books matching filter", + "description": "Preferences updated successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KomgaPage_KomgaBookDto" + "$ref": "#/components/schemas/SetPreferencesResponse" } } } }, + "400": { + "description": "Invalid preference key or value" + }, "401": { "description": "Unauthorized" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -10108,185 +10328,140 @@ ] } }, - "/{prefix}/api/v1/books/ondeck": { + "/api/v1/user/preferences/{key}": { "get": { "tags": [ - "Komga" + "User Preferences" ], - "summary": "Get \"on deck\" books", - "description": "Returns books that are currently in-progress (started but not completed).\nThis is the \"continue reading\" shelf in Komic.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/ondeck`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_books_ondeck", + "summary": "Get a single preference by key", + "operationId": "get_preference", "parameters": [ { - "name": "prefix", + "name": "key", "in": "path", - "description": "Komga API prefix (default: komga)", + "description": "Preference key (e.g., 'ui.theme')", "required": true, "schema": { "type": "string" } - }, - { - "name": "page", - "in": "query", - "description": "Page number (0-indexed, Komga-style)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "size", - "in": "query", - "description": "Page size (default: 20)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "sort", - "in": "query", - "description": "Sort parameter (e.g., \"createdDate,desc\", \"metadata.numberSort,asc\")", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } } ], "responses": { "200": { - "description": "Paginated list of in-progress books", + "description": "Preference retrieved", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KomgaPage_KomgaBookDto" + "$ref": "#/components/schemas/UserPreferenceDto" } } } }, "401": { "description": "Unauthorized" + }, + "404": { + "description": "Preference not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - } - }, - "/{prefix}/api/v1/books/{book_id}": { - "get": { + }, + "put": { "tags": [ - "Komga" + "User Preferences" ], - "summary": "Get a book by ID", - "description": "Returns a single book in Komga-compatible format.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_book", + "summary": "Set a single preference value", + "operationId": "set_preference", "parameters": [ { - "name": "prefix", + "name": "key", "in": "path", - "description": "Komga API prefix (default: komga)", + "description": "Preference key (e.g., 'ui.theme')", "required": true, "schema": { "type": "string" } - }, - { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetPreferenceRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Book details", + "description": "Preference set successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KomgaBookDto" + "$ref": "#/components/schemas/UserPreferenceDto" } } } }, + "400": { + "description": "Invalid preference value" + }, "401": { "description": "Unauthorized" - }, - "404": { - "description": "Book not found" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] } ] - } - }, - "/{prefix}/api/v1/books/{book_id}/file": { - "get": { + }, + "delete": { "tags": [ - "Komga" + "User Preferences" ], - "summary": "Download book file", - "description": "Streams the original book file (CBZ, CBR, EPUB, PDF) for download.\nIncludes proper Content-Disposition header with UTF-8 encoding.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/file`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_download_book_file", + "summary": "Delete (reset) a preference to its default", + "operationId": "delete_preference", "parameters": [ { - "name": "prefix", + "name": "key", "in": "path", - "description": "Komga API prefix (default: komga)", + "description": "Preference key to delete", "required": true, "schema": { "type": "string" } - }, - { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } } ], "responses": { "200": { - "description": "Book file download", + "description": "Preference deleted", "content": { - "application/octet-stream": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeletePreferenceResponse" + } + } } }, "401": { "description": "Unauthorized" - }, - "404": { - "description": "Book not found or file missing" } }, "security": [ { - "jwt_bearer": [] + "bearer_auth": [] }, { "api_key": [] @@ -10294,51 +10469,26 @@ ] } }, - "/{prefix}/api/v1/books/{book_id}/next": { + "/api/v1/user/ratings": { "get": { "tags": [ - "Komga" - ], - "summary": "Get next book in series", - "description": "Returns the next book in the same series by sort order.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/next`\n\n## Response\n- 200: Next book DTO\n- 404: No next book (this is the last book in series)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_next_book", - "parameters": [ - { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Ratings" ], + "summary": "List all of the current user's ratings", + "operationId": "list_user_ratings", "responses": { "200": { - "description": "Next book in series", + "description": "List of user's ratings", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KomgaBookDto" + "$ref": "#/components/schemas/UserRatingsListResponse" } } } }, - "401": { - "description": "Unauthorized" - }, - "404": { - "description": "No next book" + "403": { + "description": "Forbidden" } }, "security": [ @@ -10351,54 +10501,23 @@ ] } }, - "/{prefix}/api/v1/books/{book_id}/pages": { + "/api/v1/user/sharing-tags": { "get": { "tags": [ - "Komga" - ], - "summary": "List all pages for a book", - "description": "Returns an array of page metadata for all pages in a book.\nPages are ordered by page number (1-indexed).\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/pages`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key\n\n## Response\nReturns an array of `KomgaPageDto` objects with page metadata including\nfilename, MIME type, dimensions, and size.", - "operationId": "komga_list_pages", - "parameters": [ - { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Sharing Tags" ], + "summary": "Get current user's sharing tag grants", + "operationId": "get_my_sharing_tags", "responses": { "200": { - "description": "List of pages in the book", + "description": "List of sharing tag grants for the current user", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KomgaPageDto" - } + "$ref": "#/components/schemas/UserSharingTagGrantsResponse" } } } - }, - "401": { - "description": "Unauthorized" - }, - "404": { - "description": "Book not found" } }, "security": [ @@ -10411,57 +10530,90 @@ ] } }, - "/{prefix}/api/v1/books/{book_id}/pages/{page_number}": { + "/api/v1/users": { "get": { "tags": [ - "Komga" + "Users" ], - "summary": "Get a specific page image", - "description": "Streams the raw page image for the requested page number.\nPage numbers are 1-indexed.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/pages/{pageNumber}`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key (via cookie fallback for browser image tags)\n\n## Response\nReturns the raw image data with appropriate Content-Type header.\nResponse is cached for 1 year (immutable content).", - "operationId": "komga_get_page", + "summary": "List all users (admin only) with pagination and filtering", + "operationId": "list_users", "parameters": [ { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, + "name": "role", + "in": "query", + "description": "Filter by role", + "required": false, "schema": { - "type": "string" + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/UserRole" + } + ] } }, { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, + "name": "sharingTag", + "in": "query", + "description": "Filter by sharing tag name (users who have a grant for this tag)", + "required": false, "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "page_number", - "in": "path", - "description": "Page number (1-indexed)", - "required": true, + "type": [ + "string", + "null" + ] + } + }, + { + "name": "sharingTagMode", + "in": "query", + "description": "Filter by sharing tag access mode (allow/deny) - only used with sharing_tag", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "page", + "in": "query", + "description": "Page number (1-indexed, default 1)", + "required": false, "schema": { "type": "integer", - "format": "int32" + "format": "int64", + "minimum": 0 + } + }, + { + "name": "pageSize", + "in": "query", + "description": "Number of items per page (max 100, default 50)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 } } ], "responses": { "200": { - "description": "Page image", + "description": "Paginated list of users", "content": { - "image/*": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse_UserDto" + } + } } }, - "401": { - "description": "Unauthorized" - }, - "404": { - "description": "Book or page not found" + "403": { + "description": "Forbidden - Admin only" } }, "security": [ @@ -10472,59 +10624,39 @@ "api_key": [] } ] - } - }, - "/{prefix}/api/v1/books/{book_id}/pages/{page_number}/thumbnail": { - "get": { + }, + "post": { "tags": [ - "Komga" + "Users" ], - "summary": "Get a page thumbnail", - "description": "Returns a thumbnail version of the requested page.\nThumbnails are resized to max 300px width/height while maintaining aspect ratio.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/pages/{pageNumber}/thumbnail`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key (via cookie fallback for browser image tags)\n\n## Response\nReturns a JPEG thumbnail with appropriate caching headers.", - "operationId": "komga_get_page_thumbnail", - "parameters": [ - { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "book_id", - "in": "path", - "description": "Book ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" + "summary": "Create a new user (admin only)", + "operationId": "create_user", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUserRequest" + } } }, - { - "name": "page_number", - "in": "path", - "description": "Page number (1-indexed)", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - } - ], + "required": true + }, "responses": { - "200": { - "description": "Page thumbnail image", + "201": { + "description": "User created", "content": { - "image/jpeg": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } } }, - "401": { - "description": "Unauthorized" + "400": { + "description": "Invalid request" }, - "404": { - "description": "Book or page not found" + "403": { + "description": "Forbidden - Admin only" } }, "security": [ @@ -10537,28 +10669,18 @@ ] } }, - "/{prefix}/api/v1/books/{book_id}/previous": { + "/api/v1/users/{user_id}": { "get": { "tags": [ - "Komga" + "Users" ], - "summary": "Get previous book in series", - "description": "Returns the previous book in the same series by sort order.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/previous`\n\n## Response\n- 200: Previous book DTO\n- 404: No previous book (this is the first book in series)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_previous_book", + "summary": "Get user by ID (admin only)", + "operationId": "get_user", "parameters": [ { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "book_id", + "name": "user_id", "in": "path", - "description": "Book ID", + "description": "User ID", "required": true, "schema": { "type": "string", @@ -10568,20 +10690,20 @@ ], "responses": { "200": { - "description": "Previous book in series", + "description": "User details with sharing tags", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KomgaBookDto" + "$ref": "#/components/schemas/UserDetailDto" } } } }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden - Admin only" }, "404": { - "description": "No previous book" + "description": "User not found" } }, "security": [ @@ -10592,30 +10714,18 @@ "api_key": [] } ] - } - }, - "/{prefix}/api/v1/books/{book_id}/read-progress": { + }, "delete": { "tags": [ - "Komga" + "Users" ], - "summary": "Delete reading progress for a book (mark as unread)", - "description": "Removes all reading progress for a book, effectively marking it as unread.\n\n## Endpoint\n`DELETE /{prefix}/api/v1/books/{bookId}/read-progress`\n\n## Response\n- 204 No Content on success\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_delete_progress", + "summary": "Delete a user (admin only)", + "operationId": "delete_user", "parameters": [ { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "book_id", + "name": "user_id", "in": "path", - "description": "Book ID", + "description": "User ID", "required": true, "schema": { "type": "string", @@ -10625,13 +10735,13 @@ ], "responses": { "204": { - "description": "Progress deleted successfully" + "description": "User deleted" }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden - Admin only" }, "404": { - "description": "Book not found" + "description": "User not found" } }, "security": [ @@ -10645,25 +10755,15 @@ }, "patch": { "tags": [ - "Komga" + "Users" ], - "summary": "Update reading progress for a book", - "description": "Updates the user's reading progress for a specific book.\nKomic sends: `{ \"completed\": false, \"page\": 151 }`\n\n## Endpoint\n`PATCH /{prefix}/api/v1/books/{bookId}/read-progress`\n\n## Request Body\n- `page` - Current page number (1-indexed, optional)\n- `completed` - Whether book is completed (optional)\n- `device_id` - Device ID (optional, not used by Komic)\n- `device_name` - Device name (optional, not used by Komic)\n\n## Response\n- 204 No Content on success (Komga behavior)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_update_progress", + "summary": "Update a user (admin only, partial update)", + "operationId": "update_user", "parameters": [ { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "book_id", + "name": "user_id", "in": "path", - "description": "Book ID", + "description": "User ID", "required": true, "schema": { "type": "string", @@ -10675,21 +10775,28 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KomgaReadProgressUpdateDto" + "$ref": "#/components/schemas/UpdateUserRequest" } } }, "required": true }, "responses": { - "204": { - "description": "Progress updated successfully" + "200": { + "description": "User updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + } }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden - Admin only" }, "404": { - "description": "Book not found" + "description": "User not found" } }, "security": [ @@ -10702,28 +10809,18 @@ ] } }, - "/{prefix}/api/v1/books/{book_id}/thumbnail": { + "/api/v1/users/{user_id}/sharing-tags": { "get": { "tags": [ - "Komga" + "Sharing Tags" ], - "summary": "Get book thumbnail", - "description": "Returns a thumbnail image for the book's first page.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/thumbnail`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_book_thumbnail", + "summary": "Get sharing tag grants for a user (admin only)", + "operationId": "get_user_sharing_tags", "parameters": [ { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "book_id", + "name": "user_id", "in": "path", - "description": "Book ID", + "description": "User ID", "required": true, "schema": { "type": "string", @@ -10733,16 +10830,17 @@ ], "responses": { "200": { - "description": "Book thumbnail image", + "description": "List of sharing tag grants for the user", "content": { - "image/jpeg": {} - } - }, - "401": { - "description": "Unauthorized" + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserSharingTagGrantsResponse" + } + } + } }, - "404": { - "description": "Book not found or has no pages" + "403": { + "description": "Forbidden - Missing permission" } }, "security": [ @@ -10753,43 +10851,51 @@ "api_key": [] } ] - } - }, - "/{prefix}/api/v1/libraries": { - "get": { + }, + "put": { "tags": [ - "Komga" + "Sharing Tags" ], - "summary": "List all libraries", - "description": "Returns all libraries in Komga-compatible format.\n\n## Endpoint\n`GET /{prefix}/api/v1/libraries`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_list_libraries", + "summary": "Set a user's sharing tag grant (admin only)", + "operationId": "set_user_sharing_tag", "parameters": [ { - "name": "prefix", + "name": "user_id", "in": "path", - "description": "Komga API prefix (default: komga)", + "description": "User ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetUserSharingTagGrantRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "List of libraries", + "description": "Sharing tag grant set", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KomgaLibraryDto" - } + "$ref": "#/components/schemas/UserSharingTagGrantDto" } } } }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden - Missing permission" + }, + "404": { + "description": "Sharing tag not found" } }, "security": [ @@ -10802,28 +10908,28 @@ ] } }, - "/{prefix}/api/v1/libraries/{library_id}": { - "get": { + "/api/v1/users/{user_id}/sharing-tags/{tag_id}": { + "delete": { "tags": [ - "Komga" + "Sharing Tags" ], - "summary": "Get library by ID", - "description": "Returns a single library in Komga-compatible format.\n\n## Endpoint\n`GET /{prefix}/api/v1/libraries/{libraryId}`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_library", + "summary": "Remove a user's sharing tag grant (admin only)", + "operationId": "remove_user_sharing_tag", "parameters": [ { - "name": "prefix", + "name": "user_id", "in": "path", - "description": "Komga API prefix (default: komga)", + "description": "User ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } }, { - "name": "library_id", + "name": "tag_id", "in": "path", - "description": "Library ID", + "description": "Sharing tag ID", "required": true, "schema": { "type": "string", @@ -10832,21 +10938,14 @@ } ], "responses": { - "200": { - "description": "Library details", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/KomgaLibraryDto" - } - } - } + "204": { + "description": "Sharing tag grant removed" }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden - Missing permission" }, "404": { - "description": "Library not found" + "description": "Grant not found" } }, "security": [ @@ -10859,28 +10958,66 @@ ] } }, - "/{prefix}/api/v1/libraries/{library_id}/thumbnail": { + "/health": { "get": { "tags": [ - "Komga" + "Health" ], - "summary": "Get library thumbnail", - "description": "Returns a thumbnail image for the library. Uses the first series' cover\nas the library thumbnail, or returns a 404 if no series exist.\n\n## Endpoint\n`GET /{prefix}/api/v1/libraries/{libraryId}/thumbnail`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key (via cookie fallback for browser image tags)", - "operationId": "komga_get_library_thumbnail", - "parameters": [ - { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" + "summary": "Health check endpoint - checks database connectivity", + "description": "Returns \"OK\" with 200 status if database is healthy,\nor \"Service Unavailable\" with 503 status if database check fails.", + "operationId": "health_check", + "responses": { + "200": { + "description": "Service is healthy" + }, + "503": { + "description": "Service is unavailable" + } + } + } + }, + "/opds": { + "get": { + "tags": [ + "OPDS" + ], + "summary": "Root OPDS catalog", + "description": "Returns the main navigation feed with links to:\n- All libraries\n- Search\n- Recent additions", + "operationId": "root_catalog", + "responses": { + "200": { + "description": "OPDS root catalog", + "content": { + "application/atom+xml": {} } }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "jwt_bearer": [] + }, { - "name": "library_id", + "api_key": [] + } + ] + } + }, + "/opds/books/{book_id}/pages": { + "get": { + "tags": [ + "OPDS" + ], + "summary": "OPDS-PSE: List all pages in a book", + "description": "Returns a PSE page feed with individual page links for streaming.\nThis allows OPDS clients to read books page-by-page without downloading the entire file.", + "operationId": "opds_book_pages", + "parameters": [ + { + "name": "book_id", "in": "path", - "description": "Library ID", + "description": "Book ID", "required": true, "schema": { "type": "string", @@ -10890,16 +11027,16 @@ ], "responses": { "200": { - "description": "Library thumbnail image", + "description": "OPDS-PSE page feed", "content": { - "image/jpeg": {} + "application/atom+xml": {} } }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden" }, "404": { - "description": "Library not found or no series in library" + "description": "Book not found" } }, "security": [ @@ -10912,100 +11049,92 @@ ] } }, - "/{prefix}/api/v1/series": { + "/opds/libraries": { "get": { "tags": [ - "Komga" + "OPDS" ], - "summary": "List all series (paginated)", - "description": "Returns all series in Komga-compatible format with pagination.\n\n## Endpoint\n`GET /{prefix}/api/v1/series`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n- `library_id` - Optional filter by library UUID\n- `search` - Optional search query\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_list_series", + "summary": "List all libraries", + "description": "Returns a navigation feed with all available libraries", + "operationId": "opds_list_libraries", + "responses": { + "200": { + "description": "OPDS libraries feed", + "content": { + "application/atom+xml": {} + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/opds/libraries/{library_id}": { + "get": { + "tags": [ + "OPDS" + ], + "summary": "List series in a library", + "description": "Returns an acquisition feed with all series in the specified library", + "operationId": "opds_library_series", "parameters": [ { - "name": "prefix", + "name": "library_id", "in": "path", - "description": "Komga API prefix (default: komga)", + "description": "Library ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } }, { "name": "page", "in": "query", - "description": "Page number (0-indexed, Komga-style)", "required": false, "schema": { "type": "integer", - "format": "int32" + "format": "int32", + "minimum": 0 } }, { - "name": "size", + "name": "pageSize", "in": "query", - "description": "Page size (default: 20)", "required": false, "schema": { "type": "integer", - "format": "int32" + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "OPDS library series feed", + "content": { + "application/atom+xml": {} } }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Library not found" + } + }, + "security": [ { - "name": "library_id", - "in": "query", - "description": "Filter by library ID", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "search", - "in": "query", - "description": "Search query", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "sort", - "in": "query", - "description": "Sort parameter (e.g., \"metadata.titleSort,asc\", \"createdDate,desc\")", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - } - ], - "responses": { - "200": { - "description": "Paginated list of series", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/KomgaPage_KomgaSeriesDto" - } - } - } - }, - "401": { - "description": "Unauthorized" - } - }, - "security": [ - { - "jwt_bearer": [] + "jwt_bearer": [] }, { "api_key": [] @@ -11013,95 +11142,34 @@ ] } }, - "/{prefix}/api/v1/series/new": { + "/opds/search": { "get": { "tags": [ - "Komga" + "OPDS" ], - "summary": "Get recently added series", - "description": "Returns series sorted by created date descending (newest first).\n\n## Endpoint\n`GET /{prefix}/api/v1/series/new`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n- `library_id` - Optional filter by library UUID\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_series_new", + "summary": "OPDS search endpoint", + "description": "Searches books and series by title and returns an OPDS acquisition feed", + "operationId": "opds_search", "parameters": [ { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", + "name": "q", + "in": "query", + "description": "Search query string", "required": true, "schema": { "type": "string" } - }, - { - "name": "page", - "in": "query", - "description": "Page number (0-indexed, Komga-style)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "size", - "in": "query", - "description": "Page size (default: 20)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "library_id", - "in": "query", - "description": "Filter by library ID", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "search", - "in": "query", - "description": "Search query", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "sort", - "in": "query", - "description": "Sort parameter (e.g., \"metadata.titleSort,asc\", \"createdDate,desc\")", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } } ], "responses": { "200": { - "description": "Paginated list of recently added series", + "description": "OPDS search results", "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/KomgaPage_KomgaSeriesDto" - } - } + "application/atom+xml": {} } }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden" } }, "security": [ @@ -11114,95 +11182,20 @@ ] } }, - "/{prefix}/api/v1/series/updated": { + "/opds/search.xml": { "get": { "tags": [ - "Komga" - ], - "summary": "Get recently updated series", - "description": "Returns series sorted by last modified date descending (most recently updated first).\n\n## Endpoint\n`GET /{prefix}/api/v1/series/updated`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n- `library_id` - Optional filter by library UUID\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_series_updated", - "parameters": [ - { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "page", - "in": "query", - "description": "Page number (0-indexed, Komga-style)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "size", - "in": "query", - "description": "Page size (default: 20)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "library_id", - "in": "query", - "description": "Filter by library ID", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "search", - "in": "query", - "description": "Search query", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "sort", - "in": "query", - "description": "Sort parameter (e.g., \"metadata.titleSort,asc\", \"createdDate,desc\")", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - } + "OPDS" ], + "summary": "OpenSearch descriptor endpoint", + "description": "Returns the OpenSearch XML descriptor for OPDS clients", + "operationId": "opds_opensearch_descriptor", "responses": { "200": { - "description": "Paginated list of recently updated series", + "description": "OpenSearch descriptor", "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/KomgaPage_KomgaSeriesDto" - } - } + "application/opensearchdescription+xml": {} } - }, - "401": { - "description": "Unauthorized" } }, "security": [ @@ -11215,24 +11208,15 @@ ] } }, - "/{prefix}/api/v1/series/{series_id}": { + "/opds/series/{series_id}": { "get": { "tags": [ - "Komga" + "OPDS" ], - "summary": "Get series by ID", - "description": "Returns a single series in Komga-compatible format.\n\n## Endpoint\n`GET /{prefix}/api/v1/series/{seriesId}`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_series", + "summary": "List books in a series", + "description": "Returns an acquisition feed with all books in the specified series", + "operationId": "opds_series_books", "parameters": [ - { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" - } - }, { "name": "series_id", "in": "path", @@ -11246,17 +11230,13 @@ ], "responses": { "200": { - "description": "Series details", + "description": "OPDS series books feed", "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/KomgaSeriesDto" - } - } + "application/atom+xml": {} } }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden" }, "404": { "description": "Series not found" @@ -11272,28 +11252,85 @@ ] } }, - "/{prefix}/api/v1/series/{series_id}/books": { + "/opds/v2": { "get": { "tags": [ - "Komga" + "OPDS 2.0" ], - "summary": "Get books in a series", - "description": "Returns all books in a series with pagination.\n\n## Endpoint\n`GET /{prefix}/api/v1/series/{seriesId}/books`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_series_books", - "parameters": [ + "summary": "Root OPDS 2.0 catalog", + "description": "Returns the main navigation feed with links to:\n- All libraries\n- Search\n- Recent additions", + "operationId": "opds2_root", + "responses": { + "200": { + "description": "OPDS 2.0 root catalog", + "content": { + "application/opds+json": { + "schema": { + "$ref": "#/components/schemas/Opds2Feed" + } + } + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/opds/v2/libraries": { + "get": { + "tags": [ + "OPDS 2.0" + ], + "summary": "List all libraries (OPDS 2.0)", + "description": "Returns a navigation feed with all available libraries", + "operationId": "opds2_libraries", + "responses": { + "200": { + "description": "OPDS 2.0 libraries feed", + "content": { + "application/opds+json": { + "schema": { + "$ref": "#/components/schemas/Opds2Feed" + } + } } }, + "403": { + "description": "Forbidden" + } + }, + "security": [ { - "name": "series_id", + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/opds/v2/libraries/{library_id}": { + "get": { + "tags": [ + "OPDS 2.0" + ], + "summary": "List series in a library (OPDS 2.0)", + "description": "Returns a navigation feed with all series in the specified library", + "operationId": "opds2_library_series", + "parameters": [ + { + "name": "library_id", "in": "path", - "description": "Series ID", + "description": "Library ID", "required": true, "schema": { "type": "string", @@ -11303,77 +11340,95 @@ { "name": "page", "in": "query", - "description": "Page number (0-indexed, Komga-style)", "required": false, "schema": { "type": "integer", - "format": "int32" + "format": "int32", + "minimum": 0 } }, { - "name": "size", + "name": "pageSize", "in": "query", - "description": "Page size (default: 20)", "required": false, "schema": { "type": "integer", - "format": "int32" + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "OPDS 2.0 library series feed", + "content": { + "application/opds+json": { + "schema": { + "$ref": "#/components/schemas/Opds2Feed" + } + } } }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Library not found" + } + }, + "security": [ { - "name": "library_id", - "in": "query", - "description": "Filter by library ID", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } + "jwt_bearer": [] }, { - "name": "search", + "api_key": [] + } + ] + } + }, + "/opds/v2/recent": { + "get": { + "tags": [ + "OPDS 2.0" + ], + "summary": "List recent additions (OPDS 2.0)", + "description": "Returns a publications feed with recently added books", + "operationId": "opds2_recent", + "parameters": [ + { + "name": "page", "in": "query", - "description": "Search query", "required": false, "schema": { - "type": [ - "string", - "null" - ] + "type": "integer", + "format": "int32", + "minimum": 0 } }, { - "name": "sort", + "name": "pageSize", "in": "query", - "description": "Sort parameter (e.g., \"metadata.titleSort,asc\", \"createdDate,desc\")", "required": false, "schema": { - "type": [ - "string", - "null" - ] + "type": "integer", + "format": "int32", + "minimum": 0 } } ], "responses": { "200": { - "description": "Paginated list of books in series", + "description": "OPDS 2.0 recent additions feed", "content": { - "application/json": { + "application/opds+json": { "schema": { - "$ref": "#/components/schemas/KomgaPage_KomgaBookDto" + "$ref": "#/components/schemas/Opds2Feed" } } } }, - "401": { - "description": "Unauthorized" - }, - "404": { - "description": "Series not found" + "403": { + "description": "Forbidden" } }, "security": [ @@ -11386,47 +11441,41 @@ ] } }, - "/{prefix}/api/v1/series/{series_id}/thumbnail": { + "/opds/v2/search": { "get": { "tags": [ - "Komga" + "OPDS 2.0" ], - "summary": "Get series thumbnail", - "description": "Returns a thumbnail image for the series.\n\n## Endpoint\n`GET /{prefix}/api/v1/series/{seriesId}/thumbnail`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_series_thumbnail", + "summary": "OPDS 2.0 search endpoint", + "description": "Searches books and series by title and returns an OPDS 2.0 publications feed", + "operationId": "opds2_search", "parameters": [ { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", + "name": "query", + "in": "query", + "description": "Search query string", "required": true, "schema": { "type": "string" } - }, - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } } ], "responses": { "200": { - "description": "Series thumbnail image", + "description": "OPDS 2.0 search results", "content": { - "image/jpeg": {} + "application/opds+json": { + "schema": { + "$ref": "#/components/schemas/Opds2Feed" + } + } } }, - "401": { - "description": "Unauthorized" + "400": { + "description": "Bad request - empty query" }, - "404": { - "description": "Series not found" + "403": { + "description": "Forbidden" } }, "security": [ @@ -11439,38 +11488,42 @@ ] } }, - "/{prefix}/api/v1/users/me": { + "/opds/v2/series/{series_id}": { "get": { "tags": [ - "Komga" + "OPDS 2.0" ], - "summary": "Get current user information", - "description": "Returns information about the currently authenticated user in Komga format.\nThis endpoint is used by Komic and other apps to verify authentication\nand determine user capabilities.\n\n## Endpoint\n`GET /{prefix}/api/v1/users/me`\n\n## Response\nReturns a `KomgaUserDto` containing:\n- User ID (UUID as string)\n- Email address\n- Roles (ADMIN, USER, FILE_DOWNLOAD)\n- Library access settings\n- Content restrictions\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_current_user", + "summary": "List books in a series (OPDS 2.0)", + "description": "Returns a publications feed with all books in the specified series", + "operationId": "opds2_series_books", "parameters": [ { - "name": "prefix", + "name": "series_id", "in": "path", - "description": "Komga API prefix (default: komga)", + "description": "Series ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], "responses": { "200": { - "description": "Current user information", + "description": "OPDS 2.0 series books feed", "content": { - "application/json": { + "application/opds+json": { "schema": { - "$ref": "#/components/schemas/KomgaUserDto" + "$ref": "#/components/schemas/Opds2Feed" } } } }, - "401": { - "description": "Unauthorized" + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Series not found" } }, "security": [ @@ -11482,4119 +11535,3478 @@ } ] } - } - }, - "components": { - "schemas": { - "AccessMode": { - "type": "string", - "description": "Access mode for sharing tag grants", - "enum": [ - "allow", - "deny" - ] - }, - "AddSeriesGenreRequest": { - "type": "object", - "description": "Request to add a single genre to a series", - "required": [ - "name" + }, + "/{prefix}/api/v1/books/list": { + "post": { + "tags": [ + "Komga" ], - "properties": { - "name": { - "type": "string", - "description": "Name of the genre to add\nThe genre will be created if it doesn't exist", - "example": "Action" + "summary": "Search/filter books", + "description": "Returns books matching the filter criteria.\nThis uses POST to support complex filter bodies.\n\n## Endpoint\n`POST /{prefix}/api/v1/books/list`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n- `sort` - Sort parameter (e.g., \"createdDate,desc\")\n\n## Request Body\nJSON object with filter criteria (library_id, series_id, search_term, etc.)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_search_books", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "Page number (0-indexed, Komga-style)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "size", + "in": "query", + "description": "Page size (default: 20)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "sort", + "in": "query", + "description": "Sort parameter (e.g., \"createdDate,desc\", \"metadata.numberSort,asc\")", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } } - } - }, - "AddSeriesTagRequest": { - "type": "object", - "description": "Request to add a single tag to a series", - "required": [ - "name" ], - "properties": { - "name": { - "type": "string", - "description": "Name of the tag to add\nThe tag will be created if it doesn't exist", - "example": "Favorite" - } - } - }, - "AdjacentBooksResponse": { - "type": "object", - "description": "Response containing adjacent books in the same series\n\nReturns the previous and next books relative to the requested book,\nordered by book number within the series.", - "properties": { - "next": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/BookDto", - "description": "The next book in the series (higher number), if any" + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaBooksSearchRequestDto" } - ] + } }, - "prev": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/BookDto", - "description": "The previous book in the series (lower number), if any" + "required": true + }, + "responses": { + "200": { + "description": "Paginated list of books matching filter", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaPage_KomgaBookDto" + } } - ] - } - } - }, - "AlternateTitleDto": { - "type": "object", - "description": "Alternate title data transfer object", - "required": [ - "id", - "seriesId", - "label", - "title", - "createdAt", - "updatedAt" - ], - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the title was created", - "example": "2024-01-01T00:00:00Z" + } }, - "id": { - "type": "string", - "format": "uuid", - "description": "Alternate title ID", - "example": "550e8400-e29b-41d4-a716-446655440040" + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "label": { - "type": "string", - "description": "Label for this title (e.g., \"Japanese\", \"Romaji\", \"English\", \"Korean\")", - "example": "Japanese" + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/books/ondeck": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get \"on deck\" books", + "description": "Returns books that are currently in-progress (started but not completed).\nThis is the \"continue reading\" shelf in Komic.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/ondeck`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_books_ondeck", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "seriesId": { - "type": "string", - "format": "uuid", - "description": "Series ID", - "example": "550e8400-e29b-41d4-a716-446655440002" + { + "name": "page", + "in": "query", + "description": "Page number (0-indexed, Komga-style)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } }, - "title": { - "type": "string", - "description": "The alternate title", - "example": "進撃の巨人" + { + "name": "size", + "in": "query", + "description": "Page size (default: 20)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the title was last updated", - "example": "2024-01-15T10:30:00Z" + { + "name": "sort", + "in": "query", + "description": "Sort parameter (e.g., \"createdDate,desc\", \"metadata.numberSort,asc\")", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } } - } - }, - "AlternateTitleListResponse": { - "type": "object", - "description": "Response containing a list of alternate titles", - "required": [ - "titles" ], - "properties": { - "titles": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AlternateTitleDto" - }, - "description": "List of alternate titles" + "responses": { + "200": { + "description": "Paginated list of in-progress books", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaPage_KomgaBookDto" + } + } + } + }, + "401": { + "description": "Unauthorized" } - } - }, - "AnalysisResult": { - "type": "object", - "description": "Analysis result response", - "required": [ - "booksAnalyzed", - "errors" - ], - "properties": { - "booksAnalyzed": { - "type": "integer", - "description": "Number of books successfully analyzed", - "example": 150, - "minimum": 0 + }, + "security": [ + { + "jwt_bearer": [] }, - "errors": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of errors encountered during analysis" - } - } - }, - "ApiKeyDto": { - "type": "object", - "description": "API key data transfer object", - "required": [ - "id", - "userId", - "name", - "keyPrefix", - "permissions", - "isActive", - "createdAt", - "updatedAt" - ], - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the key was created", - "example": "2024-01-01T00:00:00Z" - }, - "expiresAt": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "When the key expires (if set)", - "example": "2025-12-31T23:59:59Z" - }, - "id": { - "type": "string", - "format": "uuid", - "description": "Unique API key identifier", - "example": "550e8400-e29b-41d4-a716-446655440000" - }, - "isActive": { - "type": "boolean", - "description": "Whether the key is currently active", - "example": true - }, - "keyPrefix": { - "type": "string", - "description": "Prefix of the key for identification", - "example": "cdx_a1b2c3" - }, - "lastUsedAt": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "When the key was last used", - "example": "2024-01-15T10:30:00Z" - }, - "name": { - "type": "string", - "description": "Human-readable name for the key", - "example": "Mobile App Key" - }, - "permissions": { - "description": "Permissions granted to this key" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the key was last updated", - "example": "2024-01-15T10:30:00Z" - }, - "userId": { - "type": "string", - "format": "uuid", - "description": "Owner user ID", - "example": "550e8400-e29b-41d4-a716-446655440001" + { + "api_key": [] } - } - }, - "AppInfoDto": { - "type": "object", - "description": "Application information response", - "required": [ - "version", - "name" + ] + } + }, + "/{prefix}/api/v1/books/{book_id}": { + "get": { + "tags": [ + "Komga" ], - "properties": { - "name": { - "type": "string", - "description": "Application name", - "example": "codex" - }, - "version": { - "type": "string", - "description": "Application version from Cargo.toml", - "example": "1.0.0" - } - } - }, - "BelongsTo": { - "type": "object", - "description": "Series membership information", - "properties": { - "series": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/SeriesInfo", - "description": "Series information" - } - ] - } - } - }, - "BookCondition": { - "oneOf": [ + "summary": "Get a book by ID", + "description": "Returns a single book in Komga-compatible format.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_book", + "parameters": [ { - "type": "object", - "description": "All conditions must match (AND)", - "required": [ - "allOf" - ], - "properties": { - "allOf": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BookCondition" - } - } + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" } }, { - "type": "object", - "description": "Any condition must match (OR)", - "required": [ - "anyOf" - ], - "properties": { - "anyOf": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BookCondition" + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Book details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaBookDto" } } } }, - { - "type": "object", - "description": "Filter by library ID", - "required": [ - "libraryId" - ], - "properties": { - "libraryId": { - "$ref": "#/components/schemas/UuidOperator" - } - } + "401": { + "description": "Unauthorized" }, + "404": { + "description": "Book not found" + } + }, + "security": [ { - "type": "object", - "description": "Filter by series ID", - "required": [ - "seriesId" - ], - "properties": { - "seriesId": { - "$ref": "#/components/schemas/UuidOperator" - } - } + "jwt_bearer": [] }, { - "type": "object", - "description": "Filter by genre name (from parent series)", - "required": [ - "genre" - ], - "properties": { - "genre": { - "$ref": "#/components/schemas/FieldOperator" - } - } - }, + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/books/{book_id}/file": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Download book file", + "description": "Streams the original book file (CBZ, CBR, EPUB, PDF) for download.\nIncludes proper Content-Disposition header with UTF-8 encoding.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/file`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_download_book_file", + "parameters": [ { - "type": "object", - "description": "Filter by tag name (from parent series)", - "required": [ - "tag" - ], - "properties": { - "tag": { - "$ref": "#/components/schemas/FieldOperator" - } + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" } }, { - "type": "object", - "description": "Filter by book title", - "required": [ - "title" - ], - "properties": { - "title": { - "$ref": "#/components/schemas/FieldOperator" - } + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Book file download", + "content": { + "application/octet-stream": {} } }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Book not found or file missing" + } + }, + "security": [ { - "type": "object", - "description": "Filter by read status (unread, in_progress, read)", - "required": [ - "readStatus" - ], - "properties": { - "readStatus": { - "$ref": "#/components/schemas/FieldOperator" - } - } + "jwt_bearer": [] }, { - "type": "object", - "description": "Filter by books with analysis errors", - "required": [ - "hasError" - ], - "properties": { - "hasError": { - "$ref": "#/components/schemas/BoolOperator" - } - } + "api_key": [] } + ] + } + }, + "/{prefix}/api/v1/books/{book_id}/next": { + "get": { + "tags": [ + "Komga" ], - "description": "Book-level search conditions" - }, - "BookDetailResponse": { - "type": "object", - "description": "Detailed book response with metadata", - "required": [ - "book" - ], - "properties": { - "book": { - "$ref": "#/components/schemas/BookDto", - "description": "The book data" + "summary": "Get next book in series", + "description": "Returns the next book in the same series by sort order.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/next`\n\n## Response\n- 200: Next book DTO\n- 404: No next book (this is the last book in series)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_next_book", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "metadata": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/BookMetadataDto", - "description": "Optional metadata from ComicInfo.xml or similar" - } - ] + { + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } } - } - }, - "BookDto": { - "type": "object", - "description": "Book data transfer object", - "required": [ - "id", - "libraryId", - "libraryName", - "seriesId", - "seriesName", - "title", - "filePath", - "fileFormat", - "fileSize", - "fileHash", - "pageCount", - "createdAt", - "updatedAt", - "deleted" ], - "properties": { - "analysisError": { - "type": [ - "string", - "null" - ], - "description": "Error message if book analysis failed", - "example": "Failed to parse CBZ: invalid archive" + "responses": { + "200": { + "description": "Next book in series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaBookDto" + } + } + } }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the book was added to the library", - "example": "2024-01-01T00:00:00Z" + "401": { + "description": "Unauthorized" }, - "deleted": { - "type": "boolean", - "description": "Whether the book has been soft-deleted", - "example": false + "404": { + "description": "No next book" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "fileFormat": { - "type": "string", - "description": "File format (cbz, cbr, epub, pdf)", - "example": "cbz" + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/books/{book_id}/pages": { + "get": { + "tags": [ + "Komga" + ], + "summary": "List all pages for a book", + "description": "Returns an array of page metadata for all pages in a book.\nPages are ordered by page number (1-indexed).\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/pages`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key\n\n## Response\nReturns an array of `KomgaPageDto` objects with page metadata including\nfilename, MIME type, dimensions, and size.", + "operationId": "komga_list_pages", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "fileHash": { - "type": "string", - "description": "File hash for deduplication", - "example": "a1b2c3d4e5f6g7h8i9j0" + { + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "List of pages in the book", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KomgaPageDto" + } + } + } + } }, - "filePath": { - "type": "string", - "description": "Filesystem path to the book file", - "example": "/media/comics/Batman/Batman - Year One 001.cbz" + "401": { + "description": "Unauthorized" }, - "fileSize": { - "type": "integer", - "format": "int64", - "description": "File size in bytes", - "example": 52428800 + "404": { + "description": "Book not found" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "id": { - "type": "string", - "format": "uuid", - "description": "Book unique identifier", - "example": "550e8400-e29b-41d4-a716-446655440001" + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/books/{book_id}/pages/{page_number}": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get a specific page image", + "description": "Streams the raw page image for the requested page number.\nPage numbers are 1-indexed.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/pages/{pageNumber}`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key (via cookie fallback for browser image tags)\n\n## Response\nReturns the raw image data with appropriate Content-Type header.\nResponse is cached for 1 year (immutable content).", + "operationId": "komga_get_page", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "libraryId": { - "type": "string", - "format": "uuid", - "description": "Library this book belongs to", - "example": "550e8400-e29b-41d4-a716-446655440000" + { + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } }, - "libraryName": { - "type": "string", - "description": "Name of the library", - "example": "Comics" + { + "name": "page_number", + "in": "path", + "description": "Page number (1-indexed)", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Page image", + "content": { + "image/*": {} + } }, - "number": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Book number within the series", - "example": 1 + "401": { + "description": "Unauthorized" }, - "pageCount": { - "type": "integer", - "format": "int32", - "description": "Number of pages in the book", - "example": 32 + "404": { + "description": "Book or page not found" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "readProgress": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ReadProgressResponse", - "description": "User's read progress for this book" - } - ] + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/books/{book_id}/pages/{page_number}/thumbnail": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get a page thumbnail", + "description": "Returns a thumbnail version of the requested page.\nThumbnails are resized to max 300px width/height while maintaining aspect ratio.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/pages/{pageNumber}/thumbnail`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key (via cookie fallback for browser image tags)\n\n## Response\nReturns a JPEG thumbnail with appropriate caching headers.", + "operationId": "komga_get_page_thumbnail", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "readingDirection": { - "type": [ - "string", - "null" - ], - "description": "Effective reading direction (from series metadata, or library default if not set)\nValues: ltr, rtl, ttb or webtoon", - "example": "ltr" + { + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } }, - "seriesId": { - "type": "string", - "format": "uuid", - "description": "Series this book belongs to", - "example": "550e8400-e29b-41d4-a716-446655440002" + { + "name": "page_number", + "in": "path", + "description": "Page number (1-indexed)", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Page thumbnail image", + "content": { + "image/jpeg": {} + } }, - "seriesName": { - "type": "string", - "description": "Name of the series", - "example": "Batman: Year One" + "401": { + "description": "Unauthorized" }, - "title": { - "type": "string", - "description": "Book title", - "example": "Batman: Year One #1" + "404": { + "description": "Book or page not found" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "titleSort": { - "type": [ - "string", - "null" - ], - "description": "Title used for sorting (title_sort field)", - "example": "batman year one 001" + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/books/{book_id}/previous": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get previous book in series", + "description": "Returns the previous book in the same series by sort order.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/previous`\n\n## Response\n- 200: Previous book DTO\n- 404: No previous book (this is the first book in series)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_previous_book", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the book was last updated", - "example": "2024-01-15T10:30:00Z" + { + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } } - } - }, - "BookErrorDto": { - "type": "object", - "description": "A single error for a book", - "required": [ - "errorType", - "message", - "occurredAt" ], - "properties": { - "details": { - "description": "Additional error details (optional)" + "responses": { + "200": { + "description": "Previous book in series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaBookDto" + } + } + } }, - "errorType": { - "$ref": "#/components/schemas/BookErrorTypeDto", - "description": "Type of the error" + "401": { + "description": "Unauthorized" }, - "message": { - "type": "string", - "description": "Human-readable error message", - "example": "Failed to parse CBZ: invalid archive" + "404": { + "description": "No previous book" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "occurredAt": { - "type": "string", - "format": "date-time", - "description": "When the error occurred", - "example": "2024-01-15T10:30:00Z" + { + "api_key": [] } - } - }, - "BookErrorTypeDto": { - "type": "string", - "description": "Book error type", - "enum": [ - "format_detection", - "parser", - "metadata", - "thumbnail", - "page_extraction", - "pdf_rendering", - "other" ] - }, - "BookFullMetadata": { - "type": "object", - "description": "Full book metadata including all fields and their lock states", - "required": [ - "locks", - "createdAt", - "updatedAt" - ], - "properties": { - "blackAndWhite": { - "type": [ - "boolean", - "null" - ], - "description": "Whether the book is black and white", - "example": false - }, - "colorist": { - "type": [ - "string", - "null" - ], - "description": "Colorist(s)", - "example": "Richmond Lewis" - }, - "count": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Total count in series", - "example": 4 - }, - "coverArtist": { - "type": [ - "string", - "null" - ], - "description": "Cover artist(s)", - "example": "David Mazzucchelli" + } + }, + "/{prefix}/api/v1/books/{book_id}/read-progress": { + "delete": { + "tags": [ + "Komga" + ], + "summary": "Delete reading progress for a book (mark as unread)", + "description": "Removes all reading progress for a book, effectively marking it as unread.\n\n## Endpoint\n`DELETE /{prefix}/api/v1/books/{bookId}/read-progress`\n\n## Response\n- 204 No Content on success\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_delete_progress", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the metadata was created", - "example": "2024-01-01T00:00:00Z" + { + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Progress deleted successfully" }, - "day": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Publication day (1-31)", - "example": 1 + "401": { + "description": "Unauthorized" }, - "editor": { - "type": [ - "string", - "null" - ], - "description": "Editor(s)", - "example": "Dennis O'Neil" + "404": { + "description": "Book not found" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "formatDetail": { - "type": [ - "string", - "null" - ], - "description": "Format details", - "example": "Trade Paperback" + { + "api_key": [] + } + ] + }, + "patch": { + "tags": [ + "Komga" + ], + "summary": "Update reading progress for a book", + "description": "Updates the user's reading progress for a specific book.\nKomic sends: `{ \"completed\": false, \"page\": 151 }`\n\n## Endpoint\n`PATCH /{prefix}/api/v1/books/{bookId}/read-progress`\n\n## Request Body\n- `page` - Current page number (1-indexed, optional)\n- `completed` - Whether book is completed (optional)\n- `device_id` - Device ID (optional, not used by Komic)\n- `device_name` - Device name (optional, not used by Komic)\n\n## Response\n- 204 No Content on success (Komga behavior)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_update_progress", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "genre": { - "type": [ - "string", - "null" - ], - "description": "Genre", - "example": "Superhero" + { + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaReadProgressUpdateDto" + } + } }, - "imprint": { - "type": [ - "string", - "null" - ], - "description": "Imprint name", - "example": "DC Black Label" + "required": true + }, + "responses": { + "204": { + "description": "Progress updated successfully" }, - "inker": { - "type": [ - "string", - "null" - ], - "description": "Inker(s)", - "example": "David Mazzucchelli" + "401": { + "description": "Unauthorized" }, - "isbns": { - "type": [ - "string", - "null" - ], - "description": "ISBN(s)", - "example": "978-1401207526" + "404": { + "description": "Book not found" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "languageIso": { - "type": [ - "string", - "null" - ], - "description": "ISO language code", - "example": "en" + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/books/{book_id}/thumbnail": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get book thumbnail", + "description": "Returns a thumbnail image for the book's first page.\n\n## Endpoint\n`GET /{prefix}/api/v1/books/{bookId}/thumbnail`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_book_thumbnail", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "letterer": { - "type": [ - "string", - "null" - ], - "description": "Letterer(s)", - "example": "Todd Klein" + { + "name": "book_id", + "in": "path", + "description": "Book ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Book thumbnail image", + "content": { + "image/jpeg": {} + } }, - "locks": { - "$ref": "#/components/schemas/BookMetadataLocks", - "description": "Lock states for all metadata fields" + "401": { + "description": "Unauthorized" }, - "manga": { - "type": [ - "boolean", - "null" - ], - "description": "Whether the book is manga format", - "example": false + "404": { + "description": "Book not found or has no pages" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "month": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Publication month (1-12)", - "example": 2 - }, - "number": { - "type": [ - "string", - "null" - ], - "description": "Chapter/book number", - "example": "1" - }, - "penciller": { - "type": [ - "string", - "null" - ], - "description": "Penciller(s)", - "example": "David Mazzucchelli" + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/libraries": { + "get": { + "tags": [ + "Komga" + ], + "summary": "List all libraries", + "description": "Returns all libraries in Komga-compatible format.\n\n## Endpoint\n`GET /{prefix}/api/v1/libraries`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_list_libraries", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of libraries", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KomgaLibraryDto" + } + } + } + } }, - "publisher": { - "type": [ - "string", - "null" - ], - "description": "Publisher name", - "example": "DC Comics" + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "summary": { - "type": [ - "string", - "null" - ], - "description": "Book summary/description", - "example": "Bruce Wayne returns to Gotham City after years abroad." + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/libraries/{library_id}": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get library by ID", + "description": "Returns a single library in Komga-compatible format.\n\n## Endpoint\n`GET /{prefix}/api/v1/libraries/{libraryId}`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_library", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "title": { - "type": [ - "string", - "null" - ], - "description": "Book title from metadata", - "example": "Batman: Year One #1" + { + "name": "library_id", + "in": "path", + "description": "Library ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Library details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaLibraryDto" + } + } + } }, - "titleSort": { - "type": [ - "string", - "null" - ], - "description": "Sort title for ordering", - "example": "batman year one 001" + "401": { + "description": "Unauthorized" }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the metadata was last updated", - "example": "2024-01-15T10:30:00Z" + "404": { + "description": "Library not found" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "volume": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Volume number", - "example": 1 + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/libraries/{library_id}/thumbnail": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get library thumbnail", + "description": "Returns a thumbnail image for the library. Uses the first series' cover\nas the library thumbnail, or returns a 404 if no series exist.\n\n## Endpoint\n`GET /{prefix}/api/v1/libraries/{libraryId}/thumbnail`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key (via cookie fallback for browser image tags)", + "operationId": "komga_get_library_thumbnail", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "web": { - "type": [ - "string", - "null" - ], - "description": "Web URL", - "example": "https://dc.com/batman-year-one" + { + "name": "library_id", + "in": "path", + "description": "Library ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Library thumbnail image", + "content": { + "image/jpeg": {} + } }, - "writer": { - "type": [ - "string", - "null" - ], - "description": "Writer(s)", - "example": "Frank Miller" + "401": { + "description": "Unauthorized" }, - "year": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Publication year", - "example": 1987 + "404": { + "description": "Library not found or no series in library" } - } - }, - "BookListRequest": { - "type": "object", - "description": "Request body for POST /books/list\n\nPagination parameters (page, pageSize, sort) are passed as query parameters,\nnot in the request body. This enables proper HATEOAS links.", - "properties": { - "condition": { - "type": [ - "object", - "null" - ], - "description": "Filter condition (optional - no condition returns all)" - }, - "fullTextSearch": { - "type": [ - "string", - "null" - ], - "description": "Full-text search query (case-insensitive search on book title)" + }, + "security": [ + { + "jwt_bearer": [] }, - "includeDeleted": { - "type": "boolean", - "description": "Include soft-deleted books in results (default: false)" + { + "api_key": [] } - } - }, - "BookMetadataDto": { - "type": "object", - "description": "Book metadata DTO", - "required": [ - "id", - "bookId", - "writers", - "pencillers", - "inkers", - "colorists", - "letterers", - "coverArtists", - "editors" + ] + } + }, + "/{prefix}/api/v1/series": { + "get": { + "tags": [ + "Komga" ], - "properties": { - "bookId": { - "type": "string", - "format": "uuid", - "description": "Associated book ID", - "example": "550e8400-e29b-41d4-a716-446655440001" - }, - "colorists": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Colorists", - "example": [ - "Richmond Lewis" - ] - }, - "coverArtists": { - "type": "array", - "items": { + "summary": "List all series (paginated)", + "description": "Returns all series in Komga-compatible format with pagination.\n\n## Endpoint\n`GET /{prefix}/api/v1/series`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n- `library_id` - Optional filter by library UUID\n- `search` - Optional search query\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_list_series", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { "type": "string" - }, - "description": "Cover artists", - "example": [ - "David Mazzucchelli" - ] + } }, - "editors": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Editors", - "example": [ - "Dennis O'Neil" - ] + { + "name": "page", + "in": "query", + "description": "Page number (0-indexed, Komga-style)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } }, - "genre": { - "type": [ - "string", - "null" - ], - "description": "Genre", - "example": "Superhero" + { + "name": "size", + "in": "query", + "description": "Page size (default: 20)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } }, - "id": { - "type": "string", - "format": "uuid", - "description": "Metadata record ID", - "example": "550e8400-e29b-41d4-a716-446655440003" + { + "name": "library_id", + "in": "query", + "description": "Filter by library ID", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } }, - "imprint": { - "type": [ - "string", - "null" - ], - "description": "Imprint name", - "example": "DC Black Label" + { + "name": "search", + "in": "query", + "description": "Search query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } }, - "inkers": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Inkers", - "example": [ - "David Mazzucchelli" - ] + { + "name": "sort", + "in": "query", + "description": "Sort parameter (e.g., \"metadata.titleSort,asc\", \"createdDate,desc\")", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "Paginated list of series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaPage_KomgaSeriesDto" + } + } + } }, - "languageIso": { - "type": [ - "string", - "null" - ], - "description": "ISO language code", - "example": "en" + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "letterers": { - "type": "array", - "items": { + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/series/new": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get recently added series", + "description": "Returns series sorted by created date descending (newest first).\n\n## Endpoint\n`GET /{prefix}/api/v1/series/new`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n- `library_id` - Optional filter by library UUID\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_series_new", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { "type": "string" - }, - "description": "Letterers", - "example": [ - "Todd Klein" - ] + } }, - "number": { - "type": [ - "string", - "null" - ], - "description": "Issue/chapter number from metadata", - "example": "1" + { + "name": "page", + "in": "query", + "description": "Page number (0-indexed, Komga-style)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } }, - "pageCount": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Page count from metadata", - "example": 32 + { + "name": "size", + "in": "query", + "description": "Page size (default: 20)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } }, - "pencillers": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Pencillers (line artists)", - "example": [ - "David Mazzucchelli" - ] + { + "name": "library_id", + "in": "query", + "description": "Filter by library ID", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } }, - "publisher": { - "type": [ - "string", - "null" - ], - "description": "Publisher name", - "example": "DC Comics" - }, - "releaseDate": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "Release/publication date", - "example": "1987-02-01T00:00:00Z" - }, - "series": { - "type": [ - "string", - "null" - ], - "description": "Series name from metadata", - "example": "Batman: Year One" - }, - "summary": { - "type": [ - "string", - "null" - ], - "description": "Book summary/description", - "example": "Bruce Wayne returns to Gotham City after years abroad to begin his war on crime." - }, - "title": { - "type": [ - "string", - "null" - ], - "description": "Book title from metadata", - "example": "Batman: Year One #1" + { + "name": "search", + "in": "query", + "description": "Search query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } }, - "writers": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Writers/authors", - "example": [ - "Frank Miller" - ] + { + "name": "sort", + "in": "query", + "description": "Sort parameter (e.g., \"metadata.titleSort,asc\", \"createdDate,desc\")", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } } - } - }, - "BookMetadataLocks": { - "type": "object", - "description": "Book metadata lock states\n\nIndicates which metadata fields are locked (protected from automatic updates).\nWhen a field is locked, the scanner will not overwrite user-edited values.", - "required": [ - "summaryLock", - "writerLock", - "pencillerLock", - "inkerLock", - "coloristLock", - "lettererLock", - "coverArtistLock", - "editorLock", - "publisherLock", - "imprintLock", - "genreLock", - "webLock", - "languageIsoLock", - "formatDetailLock", - "blackAndWhiteLock", - "mangaLock", - "yearLock", - "monthLock", - "dayLock", - "volumeLock", - "countLock", - "isbnsLock" ], - "properties": { - "blackAndWhiteLock": { - "type": "boolean", - "description": "Whether black_and_white is locked", - "example": false - }, - "coloristLock": { - "type": "boolean", - "description": "Whether colorist is locked", - "example": false + "responses": { + "200": { + "description": "Paginated list of recently added series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaPage_KomgaSeriesDto" + } + } + } }, - "countLock": { - "type": "boolean", - "description": "Whether count is locked", - "example": false + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "coverArtistLock": { - "type": "boolean", - "description": "Whether cover artist is locked", - "example": false + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/series/updated": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get recently updated series", + "description": "Returns series sorted by last modified date descending (most recently updated first).\n\n## Endpoint\n`GET /{prefix}/api/v1/series/updated`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n- `library_id` - Optional filter by library UUID\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_series_updated", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "dayLock": { - "type": "boolean", - "description": "Whether day is locked", - "example": false + { + "name": "page", + "in": "query", + "description": "Page number (0-indexed, Komga-style)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } }, - "editorLock": { - "type": "boolean", - "description": "Whether editor is locked", - "example": false + { + "name": "size", + "in": "query", + "description": "Page size (default: 20)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } }, - "formatDetailLock": { - "type": "boolean", - "description": "Whether format_detail is locked", - "example": false + { + "name": "library_id", + "in": "query", + "description": "Filter by library ID", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } }, - "genreLock": { - "type": "boolean", - "description": "Whether genre is locked", - "example": false + { + "name": "search", + "in": "query", + "description": "Search query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } }, - "imprintLock": { - "type": "boolean", - "description": "Whether imprint is locked", - "example": false + { + "name": "sort", + "in": "query", + "description": "Sort parameter (e.g., \"metadata.titleSort,asc\", \"createdDate,desc\")", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "Paginated list of recently updated series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaPage_KomgaSeriesDto" + } + } + } }, - "inkerLock": { - "type": "boolean", - "description": "Whether inker is locked", - "example": false + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "isbnsLock": { - "type": "boolean", - "description": "Whether isbns is locked", - "example": false - }, - "languageIsoLock": { - "type": "boolean", - "description": "Whether language_iso is locked", - "example": false + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/series/{series_id}": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get series by ID", + "description": "Returns a single series in Komga-compatible format.\n\n## Endpoint\n`GET /{prefix}/api/v1/series/{seriesId}`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_series", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "lettererLock": { - "type": "boolean", - "description": "Whether letterer is locked", - "example": false + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Series details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaSeriesDto" + } + } + } }, - "mangaLock": { - "type": "boolean", - "description": "Whether manga is locked", - "example": false + "401": { + "description": "Unauthorized" }, - "monthLock": { - "type": "boolean", - "description": "Whether month is locked", - "example": false + "404": { + "description": "Series not found" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "pencillerLock": { - "type": "boolean", - "description": "Whether penciller is locked", - "example": false + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/series/{series_id}/books": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get books in a series", + "description": "Returns all books in a series with pagination.\n\n## Endpoint\n`GET /{prefix}/api/v1/series/{seriesId}/books`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_series_books", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "publisherLock": { - "type": "boolean", - "description": "Whether publisher is locked", - "example": true + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } }, - "summaryLock": { - "type": "boolean", - "description": "Whether summary is locked", - "example": false + { + "name": "page", + "in": "query", + "description": "Page number (0-indexed, Komga-style)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } }, - "volumeLock": { - "type": "boolean", - "description": "Whether volume is locked", - "example": false + { + "name": "size", + "in": "query", + "description": "Page size (default: 20)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } }, - "webLock": { - "type": "boolean", - "description": "Whether web URL is locked", - "example": false + { + "name": "library_id", + "in": "query", + "description": "Filter by library ID", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } }, - "writerLock": { - "type": "boolean", - "description": "Whether writer is locked", - "example": false + { + "name": "search", + "in": "query", + "description": "Search query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } }, - "yearLock": { - "type": "boolean", - "description": "Whether year is locked", - "example": true + { + "name": "sort", + "in": "query", + "description": "Sort parameter (e.g., \"metadata.titleSort,asc\", \"createdDate,desc\")", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } } - } - }, - "BookMetadataResponse": { - "type": "object", - "description": "Response containing book metadata", - "required": [ - "bookId", - "updatedAt" ], - "properties": { - "blackAndWhite": { - "type": [ - "boolean", - "null" - ], - "description": "Whether the book is black and white", - "example": false - }, - "bookId": { - "type": "string", - "format": "uuid", - "description": "Book ID", - "example": "550e8400-e29b-41d4-a716-446655440001" - }, - "colorist": { - "type": [ - "string", - "null" - ], - "description": "Colorist(s)", - "example": "Richmond Lewis" - }, - "count": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Total count in series", - "example": 4 + "responses": { + "200": { + "description": "Paginated list of books in series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaPage_KomgaBookDto" + } + } + } }, - "coverArtist": { - "type": [ - "string", - "null" - ], - "description": "Cover artist(s)", - "example": "David Mazzucchelli" + "401": { + "description": "Unauthorized" }, - "day": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Publication day (1-31)", - "example": 1 + "404": { + "description": "Series not found" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "editor": { - "type": [ - "string", - "null" - ], - "description": "Editor(s)", - "example": "Dennis O'Neil" - }, - "formatDetail": { - "type": [ - "string", - "null" - ], - "description": "Format details", - "example": "Trade Paperback" - }, - "genre": { - "type": [ - "string", - "null" - ], - "description": "Genre", - "example": "Superhero" - }, - "imprint": { - "type": [ - "string", - "null" - ], - "description": "Imprint name", - "example": "DC Black Label" - }, - "inker": { - "type": [ - "string", - "null" - ], - "description": "Inker(s)", - "example": "David Mazzucchelli" - }, - "isbns": { - "type": [ - "string", - "null" - ], - "description": "ISBN(s)", - "example": "978-1401207526" - }, - "languageIso": { - "type": [ - "string", - "null" - ], - "description": "ISO language code", - "example": "en" - }, - "letterer": { - "type": [ - "string", - "null" - ], - "description": "Letterer(s)", - "example": "Todd Klein" - }, - "manga": { - "type": [ - "boolean", - "null" - ], - "description": "Whether the book is manga format", - "example": false - }, - "month": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Publication month (1-12)", - "example": 2 - }, - "penciller": { - "type": [ - "string", - "null" - ], - "description": "Penciller(s)", - "example": "David Mazzucchelli" - }, - "publisher": { - "type": [ - "string", - "null" - ], - "description": "Publisher name", - "example": "DC Comics" + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/series/{series_id}/thumbnail": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get series thumbnail", + "description": "Returns a thumbnail image for the series.\n\n## Endpoint\n`GET /{prefix}/api/v1/series/{seriesId}/thumbnail`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_series_thumbnail", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } }, - "summary": { - "type": [ - "string", - "null" - ], - "description": "Book summary/description", - "example": "Bruce Wayne returns to Gotham City." + { + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Series thumbnail image", + "content": { + "image/jpeg": {} + } }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "Last update timestamp", - "example": "2024-01-15T10:30:00Z" + "401": { + "description": "Unauthorized" }, - "volume": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Volume number", - "example": 1 + "404": { + "description": "Series not found" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "web": { - "type": [ - "string", - "null" - ], - "description": "Web URL", - "example": "https://dc.com/batman-year-one" + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/users/me": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get current user information", + "description": "Returns information about the currently authenticated user in Komga format.\nThis endpoint is used by Komic and other apps to verify authentication\nand determine user capabilities.\n\n## Endpoint\n`GET /{prefix}/api/v1/users/me`\n\n## Response\nReturns a `KomgaUserDto` containing:\n- User ID (UUID as string)\n- Email address\n- Roles (ADMIN, USER, FILE_DOWNLOAD)\n- Library access settings\n- Content restrictions\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_current_user", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Current user information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaUserDto" + } + } + } }, - "writer": { - "type": [ - "string", - "null" - ], - "description": "Writer(s)", - "example": "Frank Miller" + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "jwt_bearer": [] }, - "year": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Publication year", - "example": 1987 + { + "api_key": [] } - } - }, - "BookStrategy": { + ] + } + } + }, + "components": { + "schemas": { + "AccessMode": { "type": "string", - "description": "Book naming strategy type for determining book titles\n\nDetermines how individual book titles and numbers are resolved.", + "description": "Access mode for sharing tag grants", "enum": [ - "filename", - "metadata_first", - "smart", - "series_name", - "custom" + "allow", + "deny" ] }, - "BookWithErrorsDto": { + "AddSeriesGenreRequest": { "type": "object", - "description": "A book with its associated errors", + "description": "Request to add a single genre to a series", "required": [ - "book", - "errors" + "name" ], "properties": { - "book": { - "$ref": "#/components/schemas/BookDto", - "description": "The book data" - }, - "errors": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BookErrorDto" - }, - "description": "All errors for this book" + "name": { + "type": "string", + "description": "Name of the genre to add\nThe genre will be created if it doesn't exist", + "example": "Action" } } }, - "BooksPaginationQuery": { + "AddSeriesTagRequest": { "type": "object", - "description": "Query parameters for paginated book endpoints", + "description": "Request to add a single tag to a series", + "required": [ + "name" + ], "properties": { - "page": { - "type": "integer", - "format": "int32", - "description": "Page number (0-indexed, Komga-style)" - }, - "size": { - "type": "integer", - "format": "int32", - "description": "Page size (default: 20)" + "name": { + "type": "string", + "description": "Name of the tag to add\nThe tag will be created if it doesn't exist", + "example": "Favorite" + } + } + }, + "AdjacentBooksResponse": { + "type": "object", + "description": "Response containing adjacent books in the same series\n\nReturns the previous and next books relative to the requested book,\nordered by book number within the series.", + "properties": { + "next": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/BookDto", + "description": "The next book in the series (higher number), if any" + } + ] }, - "sort": { - "type": [ - "string", - "null" - ], - "description": "Sort parameter (e.g., \"createdDate,desc\", \"metadata.numberSort,asc\")" + "prev": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/BookDto", + "description": "The previous book in the series (lower number), if any" + } + ] } } }, - "BooksWithErrorsResponse": { + "AlphabeticalGroupDto": { "type": "object", - "description": "Response for listing books with errors", + "description": "Alphabetical group with count\n\nRepresents a group of series starting with a specific letter/character\nalong with the count of series in that group.", "required": [ - "totalBooksWithErrors", - "errorCounts", - "groups", - "page", - "pageSize", - "totalPages" + "group", + "count" ], "properties": { - "errorCounts": { - "type": "object", - "description": "Count of books by error type", - "additionalProperties": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "propertyNames": { - "type": "string" - }, - "example": { - "parser": 5, - "thumbnail": 10 - } - }, - "groups": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ErrorGroupDto" - }, - "description": "Error groups with books" - }, - "page": { - "type": "integer", - "format": "int64", - "description": "Current page (0-indexed)", - "example": 0, - "minimum": 0 - }, - "pageSize": { - "type": "integer", - "format": "int64", - "description": "Page size", - "example": 20, - "minimum": 0 - }, - "totalBooksWithErrors": { + "count": { "type": "integer", "format": "int64", - "description": "Total number of books with errors", - "example": 15, - "minimum": 0 + "description": "Number of series starting with this character", + "example": 20 }, - "totalPages": { - "type": "integer", - "format": "int64", - "description": "Total number of pages", - "example": 1, - "minimum": 0 + "group": { + "type": "string", + "description": "The first character (lowercase letter, digit, or special character)", + "example": "a" } } }, - "BoolOperator": { - "oneOf": [ - { - "type": "object", - "required": [ - "operator" - ], - "properties": { - "operator": { - "type": "string", - "enum": [ - "isTrue" - ] - } - } - }, - { - "type": "object", - "required": [ - "operator" - ], - "properties": { - "operator": { - "type": "string", - "enum": [ - "isFalse" - ] - } - } - } - ], - "description": "Operators for boolean comparisons" - }, - "BrandingSettingsDto": { + "AlternateTitleDto": { "type": "object", - "description": "Branding settings DTO (unauthenticated access)\n\nContains branding-related settings that can be accessed without authentication.\nUsed on the login page and other unauthenticated UI surfaces.", + "description": "Alternate title data transfer object", "required": [ - "application_name" + "id", + "seriesId", + "label", + "title", + "createdAt", + "updatedAt" ], "properties": { - "application_name": { + "createdAt": { "type": "string", - "description": "The application name to display", - "example": "Codex" + "format": "date-time", + "description": "When the title was created", + "example": "2024-01-01T00:00:00Z" + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Alternate title ID", + "example": "550e8400-e29b-41d4-a716-446655440040" + }, + "label": { + "type": "string", + "description": "Label for this title (e.g., \"Japanese\", \"Romaji\", \"English\", \"Korean\")", + "example": "Japanese" + }, + "seriesId": { + "type": "string", + "format": "uuid", + "description": "Series ID", + "example": "550e8400-e29b-41d4-a716-446655440002" + }, + "title": { + "type": "string", + "description": "The alternate title", + "example": "進撃の巨人" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the title was last updated", + "example": "2024-01-15T10:30:00Z" } } }, - "BrowseResponse": { + "AlternateTitleListResponse": { "type": "object", + "description": "Response containing a list of alternate titles", "required": [ - "current_path", - "entries" + "titles" ], "properties": { - "current_path": { - "type": "string", - "description": "Current directory path" - }, - "entries": { + "titles": { "type": "array", "items": { - "$ref": "#/components/schemas/FileSystemEntry" + "$ref": "#/components/schemas/AlternateTitleDto" }, - "description": "List of entries in the current directory" - }, - "parent_path": { - "type": [ - "string", - "null" - ], - "description": "Parent directory path (None if at root)" + "description": "List of alternate titles" } - }, - "example": { - "current_path": "/home/user/Documents", - "entries": [ - { - "is_directory": true, - "is_readable": true, - "name": "Comics", - "path": "/home/user/Documents/Comics" - }, - { - "is_directory": true, - "is_readable": true, - "name": "Manga", - "path": "/home/user/Documents/Manga" - } - ], - "parent_path": "/home/user" } }, - "BulkSetPreferencesRequest": { + "AnalysisResult": { "type": "object", - "description": "Request to set multiple preferences at once", + "description": "Analysis result response", "required": [ - "preferences" + "booksAnalyzed", + "errors" ], "properties": { - "preferences": { - "type": "object", - "description": "Map of preference keys to values", - "additionalProperties": {}, - "propertyNames": { + "booksAnalyzed": { + "type": "integer", + "description": "Number of books successfully analyzed", + "example": 150, + "minimum": 0 + }, + "errors": { + "type": "array", + "items": { "type": "string" }, - "example": { - "reader.zoom": 150, - "ui.theme": "dark" - } + "description": "List of errors encountered during analysis" } } }, - "BulkSettingUpdate": { + "ApiKeyDto": { "type": "object", - "description": "Single setting update in a bulk operation", + "description": "API key data transfer object", "required": [ - "key", - "value" + "id", + "userId", + "name", + "keyPrefix", + "permissions", + "isActive", + "createdAt", + "updatedAt" ], "properties": { - "key": { + "createdAt": { "type": "string", - "description": "Setting key to update", - "example": "scan.concurrent_jobs" + "format": "date-time", + "description": "When the key was created", + "example": "2024-01-01T00:00:00Z" }, - "value": { - "type": "string", - "description": "New value for the setting", - "example": "4" - } - } - }, - "BulkUpdateSettingsRequest": { - "type": "object", - "description": "Bulk update settings request", - "required": [ - "updates" - ], - "properties": { - "change_reason": { + "expiresAt": { "type": [ "string", "null" ], - "description": "Optional reason for the changes (for audit log)", - "example": "Batch configuration update for production" - }, - "updates": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BulkSettingUpdate" - }, - "description": "List of settings to update" - } - } - }, - "CalibreSeriesMode": { - "type": "string", - "description": "How Calibre strategy groups books into series", - "enum": [ - "standalone", - "by_author", - "from_metadata" - ] - }, - "CalibreStrategyConfig": { - "type": "object", - "description": "Configuration for Calibre strategy", - "properties": { - "authorFromFolder": { - "type": "boolean", - "description": "Use author folder name as author metadata" - }, - "readOpfMetadata": { - "type": "boolean", - "description": "Read metadata.opf files for rich metadata" + "format": "date-time", + "description": "When the key expires (if set)", + "example": "2025-12-31T23:59:59Z" }, - "seriesMode": { - "$ref": "#/components/schemas/CalibreSeriesMode", - "description": "How to group books into series" + "id": { + "type": "string", + "format": "uuid", + "description": "Unique API key identifier", + "example": "550e8400-e29b-41d4-a716-446655440000" }, - "stripIdSuffix": { + "isActive": { "type": "boolean", - "description": "Strip Calibre ID suffix from folder names (e.g., \" (123)\")" - } - } - }, - "CleanupResultDto": { - "type": "object", - "description": "Result of a cleanup operation", - "required": [ - "thumbnails_deleted", - "covers_deleted", - "bytes_freed", - "failures" - ], - "properties": { - "bytes_freed": { - "type": "integer", - "format": "int64", - "description": "Total bytes freed by deletion", - "example": 1073741824, - "minimum": 0 + "description": "Whether the key is currently active", + "example": true }, - "covers_deleted": { - "type": "integer", - "format": "int32", - "description": "Number of cover files deleted", - "example": 5, - "minimum": 0 + "keyPrefix": { + "type": "string", + "description": "Prefix of the key for identification", + "example": "cdx_a1b2c3" }, - "errors": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Error messages for any failed deletions" + "lastUsedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the key was last used", + "example": "2024-01-15T10:30:00Z" }, - "failures": { - "type": "integer", - "format": "int32", - "description": "Number of files that failed to delete", - "example": 0, - "minimum": 0 + "name": { + "type": "string", + "description": "Human-readable name for the key", + "example": "Mobile App Key" }, - "thumbnails_deleted": { - "type": "integer", - "format": "int32", - "description": "Number of thumbnail files deleted", - "example": 42, - "minimum": 0 - } - } - }, - "ConfigureSettingsRequest": { - "type": "object", - "description": "Configure initial settings request", - "required": [ - "settings", - "skipConfiguration" - ], - "properties": { - "settings": { - "type": "object", - "description": "Settings to configure (key-value pairs)", - "additionalProperties": { - "type": "string" - }, - "propertyNames": { - "type": "string" - } + "permissions": { + "description": "Permissions granted to this key" }, - "skipConfiguration": { - "type": "boolean", - "description": "Whether to skip settings configuration" - } - } - }, - "ConfigureSettingsResponse": { - "type": "object", - "description": "Configure settings response", - "required": [ - "message", - "settingsConfigured" - ], - "properties": { - "message": { + "updatedAt": { "type": "string", - "description": "Success message" + "format": "date-time", + "description": "When the key was last updated", + "example": "2024-01-15T10:30:00Z" }, - "settingsConfigured": { - "type": "integer", - "description": "Number of settings configured", - "minimum": 0 + "userId": { + "type": "string", + "format": "uuid", + "description": "Owner user ID", + "example": "550e8400-e29b-41d4-a716-446655440001" } } }, - "Contributor": { + "AppInfoDto": { "type": "object", - "description": "Contributor information (author, artist, etc.)", + "description": "Application information response", "required": [ + "version", "name" ], "properties": { "name": { "type": "string", - "description": "Name of the contributor" + "description": "Application name", + "example": "codex" }, - "sortAs": { - "type": [ - "string", - "null" - ], - "description": "Sort-friendly version of the name" + "version": { + "type": "string", + "description": "Application version from Cargo.toml", + "example": "1.0.0" } } }, - "CreateAlternateTitleRequest": { + "BelongsTo": { "type": "object", - "description": "Request to create an alternate title for a series", - "required": [ - "label", - "title" - ], + "description": "Series membership information", "properties": { - "label": { - "type": "string", - "description": "Label for this title (e.g., \"Japanese\", \"Romaji\", \"English\")", - "example": "Japanese" - }, - "title": { - "type": "string", - "description": "The alternate title", - "example": "進撃の巨人" + "series": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SeriesInfo", + "description": "Series information" + } + ] } } }, - "CreateApiKeyRequest": { - "type": "object", - "description": "Create API key request", - "required": [ - "name" - ], - "properties": { - "expiresAt": { - "type": [ - "string", - "null" + "BookCondition": { + "oneOf": [ + { + "type": "object", + "description": "All conditions must match (AND)", + "required": [ + "allOf" ], - "format": "date-time", - "description": "Optional expiration date", - "example": "2025-12-31T23:59:59Z" - }, - "name": { - "type": "string", - "description": "Name/description for the API key", - "example": "Mobile App Key" + "properties": { + "allOf": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookCondition" + } + } + } }, - "permissions": { - "type": [ - "array", - "null" + { + "type": "object", + "description": "Any condition must match (OR)", + "required": [ + "anyOf" ], - "items": { - "type": "string" - }, - "description": "Permissions for the API key (array of permission strings)\nIf not provided, uses the user's current permissions" - } - } - }, - "CreateApiKeyResponse": { - "allOf": [ + "properties": { + "anyOf": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookCondition" + } + } + } + }, { - "$ref": "#/components/schemas/ApiKeyDto" + "type": "object", + "description": "Filter by library ID", + "required": [ + "libraryId" + ], + "properties": { + "libraryId": { + "$ref": "#/components/schemas/UuidOperator" + } + } }, { "type": "object", + "description": "Filter by series ID", "required": [ - "key" + "seriesId" ], "properties": { - "key": { - "type": "string", - "description": "The plaintext API key (only shown once on creation)", - "example": "cdx_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" + "seriesId": { + "$ref": "#/components/schemas/UuidOperator" } } - } - ], - "description": "API key creation response (includes plaintext key only on creation)" - }, - "CreateExternalLinkRequest": { - "type": "object", - "description": "Request to create or update an external link for a series", - "required": [ - "sourceName", - "url" - ], - "properties": { - "externalId": { - "type": [ - "string", - "null" + }, + { + "type": "object", + "description": "Filter by genre name (from parent series)", + "required": [ + "genre" ], - "description": "ID on the external source (if available)", - "example": "12345" + "properties": { + "genre": { + "$ref": "#/components/schemas/FieldOperator" + } + } }, - "sourceName": { - "type": "string", - "description": "Source name (e.g., \"myanimelist\", \"anilist\", \"mangadex\")\nWill be normalized to lowercase", - "example": "myanimelist" + { + "type": "object", + "description": "Filter by tag name (from parent series)", + "required": [ + "tag" + ], + "properties": { + "tag": { + "$ref": "#/components/schemas/FieldOperator" + } + } }, - "url": { - "type": "string", - "description": "URL to the external source", - "example": "https://myanimelist.net/manga/12345" - } - } - }, - "CreateExternalRatingRequest": { - "type": "object", - "description": "Request to create or update an external rating for a series", - "required": [ - "sourceName", - "rating" - ], - "properties": { - "rating": { - "type": "number", - "format": "double", - "description": "Rating value (0-100)", - "example": 85.5 + { + "type": "object", + "description": "Filter by book title", + "required": [ + "title" + ], + "properties": { + "title": { + "$ref": "#/components/schemas/FieldOperator" + } + } }, - "sourceName": { - "type": "string", - "description": "Source name (e.g., \"myanimelist\", \"anilist\", \"mangabaka\")\nWill be normalized to lowercase", - "example": "myanimelist" + { + "type": "object", + "description": "Filter by read status (unread, in_progress, read)", + "required": [ + "readStatus" + ], + "properties": { + "readStatus": { + "$ref": "#/components/schemas/FieldOperator" + } + } }, - "voteCount": { - "type": [ - "integer", - "null" + { + "type": "object", + "description": "Filter by books with analysis errors", + "required": [ + "hasError" ], - "format": "int32", - "description": "Number of votes (if available)", - "example": 12500 + "properties": { + "hasError": { + "$ref": "#/components/schemas/BoolOperator" + } + } } - } + ], + "description": "Book-level search conditions" }, - "CreateLibraryRequest": { + "BookDetailResponse": { "type": "object", - "description": "Create library request", + "description": "Detailed book response with metadata", "required": [ - "name", - "path" + "book" ], "properties": { - "allowedFormats": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - }, - "description": "Allowed file formats (e.g., [\"CBZ\", \"CBR\", \"EPUB\"])", - "example": [ - "CBZ", - "CBR", - "EPUB" - ] - }, - "bookConfig": { - "description": "Book strategy-specific configuration (JSON, mutable after creation)" + "book": { + "$ref": "#/components/schemas/BookDto", + "description": "The book data" }, - "bookStrategy": { + "metadata": { "oneOf": [ { "type": "null" }, { - "$ref": "#/components/schemas/BookStrategy", - "description": "Book naming strategy (mutable after creation)\nOptions: filename, metadata_first, smart, series_name" + "$ref": "#/components/schemas/BookMetadataDto", + "description": "Optional metadata from ComicInfo.xml or similar" } ] - }, - "defaultReadingDirection": { + } + } + }, + "BookDto": { + "type": "object", + "description": "Book data transfer object", + "required": [ + "id", + "libraryId", + "libraryName", + "seriesId", + "seriesName", + "title", + "filePath", + "fileFormat", + "fileSize", + "fileHash", + "pageCount", + "createdAt", + "updatedAt", + "deleted" + ], + "properties": { + "analysisError": { "type": [ "string", "null" ], - "description": "Default reading direction for books in this library (ltr, rtl, ttb or webtoon)", - "example": "ltr" + "description": "Error message if book analysis failed", + "example": "Failed to parse CBZ: invalid archive" }, - "description": { - "type": [ - "string", - "null" - ], - "description": "Optional description", - "example": "My comic book collection" + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the book was added to the library", + "example": "2024-01-01T00:00:00Z" }, - "excludedPatterns": { - "type": [ - "string", - "null" - ], - "description": "Excluded path patterns (newline-separated, e.g., \".DS_Store\\nThumbs.db\")", - "example": ".DS_Store\nThumbs.db" + "deleted": { + "type": "boolean", + "description": "Whether the book has been soft-deleted", + "example": false }, - "name": { + "fileFormat": { "type": "string", - "description": "Library name", - "example": "Comics" + "description": "File format (cbz, cbr, epub, pdf)", + "example": "cbz" }, - "numberConfig": { - "description": "Number strategy-specific configuration (JSON, mutable after creation)" + "fileHash": { + "type": "string", + "description": "File hash for deduplication", + "example": "a1b2c3d4e5f6g7h8i9j0" }, - "numberStrategy": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NumberStrategy", - "description": "Book number strategy (mutable after creation)\nOptions: file_order, metadata, filename, smart" - } - ] + "filePath": { + "type": "string", + "description": "Filesystem path to the book file", + "example": "/media/comics/Batman/Batman - Year One 001.cbz" }, - "path": { + "fileSize": { + "type": "integer", + "format": "int64", + "description": "File size in bytes", + "example": 52428800 + }, + "id": { "type": "string", - "description": "Filesystem path to the library", - "example": "/media/comics" + "format": "uuid", + "description": "Book unique identifier", + "example": "550e8400-e29b-41d4-a716-446655440001" }, - "scanImmediately": { - "type": "boolean", - "description": "Scan immediately after creation (not stored in DB)", - "example": true + "libraryId": { + "type": "string", + "format": "uuid", + "description": "Library this book belongs to", + "example": "550e8400-e29b-41d4-a716-446655440000" }, - "scanningConfig": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ScanningConfigDto", - "description": "Scanning configuration" - } - ] + "libraryName": { + "type": "string", + "description": "Name of the library", + "example": "Comics" }, - "seriesConfig": { - "description": "Strategy-specific configuration (JSON, immutable after creation)" + "number": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Book number within the series", + "example": 1 }, - "seriesStrategy": { + "pageCount": { + "type": "integer", + "format": "int32", + "description": "Number of pages in the book", + "example": 32 + }, + "readProgress": { "oneOf": [ { "type": "null" }, { - "$ref": "#/components/schemas/SeriesStrategy", - "description": "Series detection strategy (immutable after creation)\nOptions: series_volume, series_volume_chapter, flat, publisher_hierarchy, calibre, custom" + "$ref": "#/components/schemas/ReadProgressResponse", + "description": "User's read progress for this book" } ] - } - } - }, - "CreateSharingTagRequest": { - "type": "object", - "description": "Create sharing tag request", - "required": [ - "name" - ], - "properties": { - "description": { + }, + "readingDirection": { "type": [ "string", "null" ], - "description": "Optional description", - "example": "Content appropriate for children" + "description": "Effective reading direction (from series metadata, or library default if not set)\nValues: ltr, rtl, ttb or webtoon", + "example": "ltr" }, - "name": { + "seriesId": { "type": "string", - "description": "Display name for the sharing tag (must be unique)", - "example": "Kids Content" - } - } - }, - "CreateTaskRequest": { - "type": "object", - "required": [ - "task_type" - ], - "properties": { - "priority": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Priority level (higher = more urgent)", - "example": 0 + "format": "uuid", + "description": "Series this book belongs to", + "example": "550e8400-e29b-41d4-a716-446655440002" }, - "scheduled_for": { + "seriesName": { + "type": "string", + "description": "Name of the series", + "example": "Batman: Year One" + }, + "title": { + "type": "string", + "description": "Book title", + "example": "Batman: Year One #1" + }, + "titleSort": { "type": [ "string", "null" ], - "format": "date-time", - "description": "When to run the task (defaults to now)", - "example": "2024-01-15T12:00:00Z" + "description": "Title used for sorting (title_sort field)", + "example": "batman year one 001" }, - "task_type": { - "$ref": "#/components/schemas/TaskType", - "description": "Type of task to create" - } - } - }, - "CreateTaskResponse": { - "type": "object", - "required": [ - "task_id" - ], - "properties": { - "task_id": { + "updatedAt": { "type": "string", - "format": "uuid", - "description": "ID of the created task", - "example": "550e8400-e29b-41d4-a716-446655440000" + "format": "date-time", + "description": "When the book was last updated", + "example": "2024-01-15T10:30:00Z" } } }, - "CreateUserRequest": { + "BookErrorDto": { "type": "object", - "description": "Create user request", + "description": "A single error for a book", "required": [ - "username", - "email", - "password" + "errorType", + "message", + "occurredAt" ], "properties": { - "email": { - "type": "string", - "description": "Email address for the new account", - "example": "newuser@example.com" + "details": { + "description": "Additional error details (optional)" }, - "password": { - "type": "string", - "description": "Password for the new account", - "example": "securePassword123!" + "errorType": { + "$ref": "#/components/schemas/BookErrorTypeDto", + "description": "Type of the error" }, - "role": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/UserRole", - "description": "User role (reader, maintainer, admin). Defaults to reader if not specified." - } - ] + "message": { + "type": "string", + "description": "Human-readable error message", + "example": "Failed to parse CBZ: invalid archive" }, - "username": { + "occurredAt": { "type": "string", - "description": "Username for the new account", - "example": "newuser" + "format": "date-time", + "description": "When the error occurred", + "example": "2024-01-15T10:30:00Z" } } }, - "CustomStrategyConfig": { + "BookErrorTypeDto": { + "type": "string", + "description": "Book error type", + "enum": [ + "format_detection", + "parser", + "metadata", + "thumbnail", + "page_extraction", + "pdf_rendering", + "other" + ] + }, + "BookFullMetadata": { "type": "object", - "description": "Configuration for custom series strategy\n\nNote: Volume/chapter extraction from filenames is handled by the book strategy,\nnot the series strategy. Use CustomBookConfig for regex-based volume/chapter parsing.", + "description": "Full book metadata including all fields and their lock states", "required": [ - "pattern" + "locks", + "createdAt", + "updatedAt" ], "properties": { - "pattern": { - "type": "string", - "description": "Regex pattern with named capture groups for series detection\nSupported groups: publisher, series, book\nExample: \"^(?P[^/]+)/(?P[^/]+)/(?P.+)\\\\.(cbz|cbr|epub|pdf)$\"" - }, - "seriesNameTemplate": { - "type": "string", - "description": "Template for constructing series name from capture groups\nExample: \"{publisher} - {series}\"" - } - } - }, - "DeletePreferenceResponse": { - "type": "object", - "description": "Response after deleting a preference", - "required": [ - "deleted", - "message" - ], - "properties": { - "deleted": { - "type": "boolean", - "description": "Whether a preference was deleted", - "example": true + "blackAndWhite": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the book is black and white", + "example": false }, - "message": { - "type": "string", - "description": "Message describing the result", - "example": "Preference 'ui.theme' was reset to default" - } - } - }, - "DetectedSeriesDto": { - "type": "object", - "description": "Detected series information for preview", - "required": [ - "name", - "bookCount", - "sampleBooks" - ], - "properties": { - "bookCount": { - "type": "integer", - "description": "Number of books detected", - "minimum": 0 + "colorist": { + "type": [ + "string", + "null" + ], + "description": "Colorist(s)", + "example": "Richmond Lewis" }, - "metadata": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/DetectedSeriesMetadataDto", - "description": "Metadata extracted during detection" - } - ] + "count": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Total count in series", + "example": 4 }, - "name": { + "coverArtist": { + "type": [ + "string", + "null" + ], + "description": "Cover artist(s)", + "example": "David Mazzucchelli" + }, + "createdAt": { "type": "string", - "description": "Series name as detected" + "format": "date-time", + "description": "When the metadata was created", + "example": "2024-01-01T00:00:00Z" }, - "path": { + "day": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Publication day (1-31)", + "example": 1 + }, + "editor": { "type": [ "string", "null" ], - "description": "Path relative to library root" + "description": "Editor(s)", + "example": "Dennis O'Neil" }, - "sampleBooks": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Sample book filenames (first 5)" - } - } - }, - "DetectedSeriesMetadataDto": { - "type": "object", - "description": "Metadata extracted during series detection", - "properties": { - "author": { + "formatDetail": { "type": [ "string", "null" ], - "description": "Author (for calibre strategy)" + "description": "Format details", + "example": "Trade Paperback" }, - "publisher": { + "genre": { "type": [ "string", "null" ], - "description": "Publisher (for publisher_hierarchy strategy)" - } - } - }, - "DuplicateGroup": { - "type": "object", - "description": "A group of duplicate books", - "required": [ - "id", - "file_hash", - "book_ids", - "duplicate_count", - "created_at", - "updated_at" - ], - "properties": { - "book_ids": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - }, - "description": "List of book IDs that share this hash" + "description": "Genre", + "example": "Superhero" }, - "created_at": { - "type": "string", - "description": "When the duplicate was first detected", - "example": "2024-01-15T10:30:00Z" + "imprint": { + "type": [ + "string", + "null" + ], + "description": "Imprint name", + "example": "DC Black Label" }, - "duplicate_count": { - "type": "integer", - "format": "int32", - "description": "Number of duplicate copies found", - "example": 3 + "inker": { + "type": [ + "string", + "null" + ], + "description": "Inker(s)", + "example": "David Mazzucchelli" }, - "file_hash": { - "type": "string", - "description": "SHA-256 hash of the file content", - "example": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + "isbns": { + "type": [ + "string", + "null" + ], + "description": "ISBN(s)", + "example": "978-1401207526" }, - "id": { - "type": "string", - "format": "uuid", - "description": "Unique identifier for the duplicate group", - "example": "550e8400-e29b-41d4-a716-446655440000" + "languageIso": { + "type": [ + "string", + "null" + ], + "description": "ISO language code", + "example": "en" }, - "updated_at": { - "type": "string", - "description": "When the group was last updated", - "example": "2024-01-15T10:30:00Z" - } - } - }, - "EntityChangeEvent": { - "allOf": [ - { - "$ref": "#/components/schemas/EntityEvent", - "description": "The specific event that occurred" + "letterer": { + "type": [ + "string", + "null" + ], + "description": "Letterer(s)", + "example": "Todd Klein" }, - { - "type": "object", - "required": [ - "timestamp" + "locks": { + "$ref": "#/components/schemas/BookMetadataLocks", + "description": "Lock states for all metadata fields" + }, + "manga": { + "type": [ + "boolean", + "null" ], - "properties": { - "timestamp": { - "type": "string", - "format": "date-time", - "description": "When the event occurred" - }, - "user_id": { - "type": [ - "string", - "null" - ], - "format": "uuid", - "description": "User who triggered the change (if applicable)" - } - } - } - ], - "description": "Complete entity change event with metadata" - }, - "EntityEvent": { - "oneOf": [ - { - "type": "object", - "description": "A book was created", - "required": [ - "book_id", - "series_id", - "library_id", - "type" + "description": "Whether the book is manga format", + "example": false + }, + "month": { + "type": [ + "integer", + "null" ], - "properties": { - "book_id": { - "type": "string", - "format": "uuid" - }, - "library_id": { - "type": "string", - "format": "uuid" - }, - "series_id": { - "type": "string", - "format": "uuid" - }, - "type": { - "type": "string", - "enum": [ - "book_created" - ] - } - } + "format": "int32", + "description": "Publication month (1-12)", + "example": 2 }, - { - "type": "object", - "description": "A book was updated", - "required": [ - "book_id", - "series_id", - "library_id", - "type" + "number": { + "type": [ + "string", + "null" ], - "properties": { - "book_id": { - "type": "string", - "format": "uuid" - }, - "fields": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "library_id": { - "type": "string", - "format": "uuid" - }, - "series_id": { - "type": "string", - "format": "uuid" - }, - "type": { - "type": "string", - "enum": [ - "book_updated" - ] - } - } + "description": "Chapter/book number", + "example": "1" }, - { - "type": "object", - "description": "A book was deleted", - "required": [ - "book_id", - "series_id", - "library_id", - "type" + "penciller": { + "type": [ + "string", + "null" ], - "properties": { - "book_id": { - "type": "string", - "format": "uuid" - }, - "library_id": { - "type": "string", - "format": "uuid" - }, - "series_id": { - "type": "string", - "format": "uuid" - }, - "type": { - "type": "string", - "enum": [ - "book_deleted" - ] - } - } + "description": "Penciller(s)", + "example": "David Mazzucchelli" }, - { - "type": "object", - "description": "A series was created", - "required": [ - "series_id", - "library_id", - "type" + "publisher": { + "type": [ + "string", + "null" ], - "properties": { - "library_id": { - "type": "string", - "format": "uuid" - }, - "series_id": { - "type": "string", - "format": "uuid" - }, - "type": { - "type": "string", - "enum": [ - "series_created" - ] - } - } + "description": "Publisher name", + "example": "DC Comics" }, - { - "type": "object", - "description": "A series was updated", - "required": [ - "series_id", - "library_id", - "type" + "summary": { + "type": [ + "string", + "null" ], - "properties": { - "fields": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "library_id": { - "type": "string", - "format": "uuid" - }, - "series_id": { - "type": "string", - "format": "uuid" - }, - "type": { - "type": "string", - "enum": [ - "series_updated" - ] - } - } + "description": "Book summary/description", + "example": "Bruce Wayne returns to Gotham City after years abroad." }, - { - "type": "object", - "description": "A series was deleted", - "required": [ - "series_id", - "library_id", - "type" + "title": { + "type": [ + "string", + "null" ], - "properties": { - "library_id": { - "type": "string", - "format": "uuid" - }, - "series_id": { - "type": "string", - "format": "uuid" - }, - "type": { - "type": "string", - "enum": [ - "series_deleted" - ] - } - } + "description": "Book title from metadata", + "example": "Batman: Year One #1" }, - { - "type": "object", - "description": "Deleted books were purged from a series", - "required": [ - "series_id", - "library_id", - "count", - "type" + "titleSort": { + "type": [ + "string", + "null" ], - "properties": { - "count": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "library_id": { - "type": "string", - "format": "uuid" - }, - "series_id": { - "type": "string", - "format": "uuid" - }, - "type": { - "type": "string", - "enum": [ - "series_bulk_purged" - ] - } - } + "description": "Sort title for ordering", + "example": "batman year one 001" }, - { - "type": "object", - "description": "A cover image was updated", - "required": [ - "entity_type", - "entity_id", - "type" + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the metadata was last updated", + "example": "2024-01-15T10:30:00Z" + }, + "volume": { + "type": [ + "integer", + "null" ], - "properties": { - "entity_id": { - "type": "string", - "format": "uuid" - }, - "entity_type": { - "$ref": "#/components/schemas/EntityType" - }, - "library_id": { - "type": [ - "string", - "null" - ], - "format": "uuid" - }, - "type": { - "type": "string", - "enum": [ - "cover_updated" - ] - } - } + "format": "int32", + "description": "Volume number", + "example": 1 }, - { - "type": "object", - "description": "A library was updated", - "required": [ - "library_id", - "type" + "web": { + "type": [ + "string", + "null" ], - "properties": { - "library_id": { - "type": "string", - "format": "uuid" - }, - "type": { - "type": "string", - "enum": [ - "library_updated" - ] - } - } + "description": "Web URL", + "example": "https://dc.com/batman-year-one" }, - { - "type": "object", - "description": "A library was deleted", - "required": [ - "library_id", - "type" + "writer": { + "type": [ + "string", + "null" ], - "properties": { - "library_id": { - "type": "string", - "format": "uuid" - }, - "type": { - "type": "string", - "enum": [ - "library_deleted" - ] - } - } - } - ], - "description": "Specific event types for entity changes" - }, - "EntityType": { - "type": "string", - "description": "Type of entity that was changed", - "enum": [ - "book", - "series", - "library" - ] - }, - "ErrorGroupDto": { - "type": "object", - "description": "Summary of errors grouped by type", - "required": [ - "errorType", - "label", - "count", - "books" - ], - "properties": { - "books": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BookWithErrorsDto" - }, - "description": "Books with this error type (paginated)" - }, - "count": { - "type": "integer", - "format": "int64", - "description": "Number of books with this error type", - "example": 5, - "minimum": 0 - }, - "errorType": { - "$ref": "#/components/schemas/BookErrorTypeDto", - "description": "Error type" + "description": "Writer(s)", + "example": "Frank Miller" }, - "label": { - "type": "string", - "description": "Human-readable label for this error type", - "example": "Parser Error" + "year": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Publication year", + "example": 1987 } } }, - "ErrorResponse": { + "BookListRequest": { "type": "object", - "required": [ - "error", - "message" - ], + "description": "Request body for POST /books/list\n\nPagination parameters (page, pageSize, sort) are passed as query parameters,\nnot in the request body. This enables proper HATEOAS links.", "properties": { - "details": {}, - "error": { - "type": "string" + "condition": { + "type": [ + "object", + "null" + ], + "description": "Filter condition (optional - no condition returns all)" }, - "message": { - "type": "string" + "fullTextSearch": { + "type": [ + "string", + "null" + ], + "description": "Full-text search query (case-insensitive search on book title)" + }, + "includeDeleted": { + "type": "boolean", + "description": "Include soft-deleted books in results (default: false)" } } }, - "ExternalLinkDto": { + "BookMetadataDto": { "type": "object", - "description": "External link data transfer object", + "description": "Book metadata DTO", "required": [ "id", - "seriesId", - "sourceName", - "url", - "createdAt", - "updatedAt" + "bookId", + "writers", + "pencillers", + "inkers", + "colorists", + "letterers", + "coverArtists", + "editors" ], "properties": { - "createdAt": { + "bookId": { "type": "string", - "format": "date-time", - "description": "When the link was created", - "example": "2024-01-01T00:00:00Z" + "format": "uuid", + "description": "Associated book ID", + "example": "550e8400-e29b-41d4-a716-446655440001" }, - "externalId": { + "colorists": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Colorists", + "example": [ + "Richmond Lewis" + ] + }, + "coverArtists": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Cover artists", + "example": [ + "David Mazzucchelli" + ] + }, + "editors": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Editors", + "example": [ + "Dennis O'Neil" + ] + }, + "genre": { "type": [ "string", "null" ], - "description": "ID on the external source (if available)", - "example": "12345" + "description": "Genre", + "example": "Superhero" }, "id": { "type": "string", "format": "uuid", - "description": "External link ID", - "example": "550e8400-e29b-41d4-a716-446655440060" - }, - "seriesId": { - "type": "string", - "format": "uuid", - "description": "Series ID", - "example": "550e8400-e29b-41d4-a716-446655440002" - }, - "sourceName": { - "type": "string", - "description": "Source name (e.g., \"myanimelist\", \"anilist\", \"mangadex\")", - "example": "myanimelist" + "description": "Metadata record ID", + "example": "550e8400-e29b-41d4-a716-446655440003" }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the link was last updated", - "example": "2024-01-15T10:30:00Z" + "imprint": { + "type": [ + "string", + "null" + ], + "description": "Imprint name", + "example": "DC Black Label" }, - "url": { - "type": "string", - "description": "URL to the external source", - "example": "https://myanimelist.net/manga/12345" - } - } - }, - "ExternalLinkListResponse": { - "type": "object", - "description": "Response containing a list of external links", - "required": [ - "links" - ], - "properties": { - "links": { + "inkers": { "type": "array", "items": { - "$ref": "#/components/schemas/ExternalLinkDto" + "type": "string" }, - "description": "List of external links" - } - } - }, - "ExternalRatingDto": { - "type": "object", - "description": "External rating data transfer object", - "required": [ - "id", - "seriesId", - "sourceName", - "rating", - "fetchedAt", - "createdAt", - "updatedAt" - ], - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the rating record was created", - "example": "2024-01-01T00:00:00Z" - }, - "fetchedAt": { - "type": "string", - "format": "date-time", - "description": "When the rating was last fetched from the source", - "example": "2024-01-15T10:30:00Z" - }, - "id": { - "type": "string", - "format": "uuid", - "description": "External rating ID", - "example": "550e8400-e29b-41d4-a716-446655440050" - }, - "rating": { - "type": "number", - "format": "double", - "description": "Rating value (0-100)", - "example": 85.5 + "description": "Inkers", + "example": [ + "David Mazzucchelli" + ] }, - "seriesId": { - "type": "string", - "format": "uuid", - "description": "Series ID", - "example": "550e8400-e29b-41d4-a716-446655440002" + "languageIso": { + "type": [ + "string", + "null" + ], + "description": "ISO language code", + "example": "en" }, - "sourceName": { - "type": "string", - "description": "Source name (e.g., \"myanimelist\", \"anilist\", \"mangabaka\")", - "example": "myanimelist" + "letterers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Letterers", + "example": [ + "Todd Klein" + ] }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the rating record was last updated", - "example": "2024-01-15T10:30:00Z" + "number": { + "type": [ + "string", + "null" + ], + "description": "Issue/chapter number from metadata", + "example": "1" }, - "voteCount": { + "pageCount": { "type": [ "integer", "null" ], "format": "int32", - "description": "Number of votes (if available)", - "example": 12500 - } - } - }, - "ExternalRatingListResponse": { - "type": "object", - "description": "Response containing a list of external ratings", - "required": [ - "ratings" - ], - "properties": { - "ratings": { + "description": "Page count from metadata", + "example": 32 + }, + "pencillers": { "type": "array", "items": { - "$ref": "#/components/schemas/ExternalRatingDto" + "type": "string" }, - "description": "List of external ratings" - } - } - }, - "FeedMetadata": { - "type": "object", - "description": "OPDS 2.0 Feed Metadata\n\nMetadata for navigation and publication feeds.", - "required": [ - "title" - ], - "properties": { - "currentPage": { + "description": "Pencillers (line artists)", + "example": [ + "David Mazzucchelli" + ] + }, + "publisher": { "type": [ - "integer", + "string", "null" ], - "format": "int32", - "description": "Current page number (for pagination)" + "description": "Publisher name", + "example": "DC Comics" }, - "itemsPerPage": { + "releaseDate": { "type": [ - "integer", + "string", "null" ], - "format": "int32", - "description": "Items per page (for pagination)" + "format": "date-time", + "description": "Release/publication date", + "example": "1987-02-01T00:00:00Z" }, - "modified": { + "series": { "type": [ "string", "null" ], - "format": "date-time", - "description": "Last modification date" + "description": "Series name from metadata", + "example": "Batman: Year One" }, - "numberOfItems": { + "summary": { "type": [ - "integer", + "string", "null" ], - "format": "int64", - "description": "Total number of items in the collection (for pagination)" + "description": "Book summary/description", + "example": "Bruce Wayne returns to Gotham City after years abroad to begin his war on crime." }, - "subtitle": { + "title": { "type": [ "string", "null" ], - "description": "Optional subtitle" + "description": "Book title from metadata", + "example": "Batman: Year One #1" }, - "title": { - "type": "string", - "description": "Title of the feed" + "writers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Writers/authors", + "example": [ + "Frank Miller" + ] } } }, - "FieldOperator": { - "oneOf": [ - { - "type": "object", - "description": "Exact match", - "required": [ - "value", - "operator" - ], - "properties": { - "operator": { - "type": "string", - "enum": [ - "is" - ] - }, - "value": { - "type": "string" - } - } + "BookMetadataLocks": { + "type": "object", + "description": "Book metadata lock states\n\nIndicates which metadata fields are locked (protected from automatic updates).\nWhen a field is locked, the scanner will not overwrite user-edited values.", + "required": [ + "summaryLock", + "writerLock", + "pencillerLock", + "inkerLock", + "coloristLock", + "lettererLock", + "coverArtistLock", + "editorLock", + "publisherLock", + "imprintLock", + "genreLock", + "webLock", + "languageIsoLock", + "formatDetailLock", + "blackAndWhiteLock", + "mangaLock", + "yearLock", + "monthLock", + "dayLock", + "volumeLock", + "countLock", + "isbnsLock" + ], + "properties": { + "blackAndWhiteLock": { + "type": "boolean", + "description": "Whether black_and_white is locked", + "example": false }, - { - "type": "object", - "description": "Not equal", - "required": [ - "value", - "operator" - ], - "properties": { - "operator": { - "type": "string", - "enum": [ - "isNot" - ] - }, - "value": { - "type": "string" - } - } + "coloristLock": { + "type": "boolean", + "description": "Whether colorist is locked", + "example": false }, - { - "type": "object", - "description": "Field is null/empty", - "required": [ - "operator" - ], - "properties": { - "operator": { - "type": "string", - "enum": [ - "isNull" - ] - } - } + "countLock": { + "type": "boolean", + "description": "Whether count is locked", + "example": false }, - { - "type": "object", - "description": "Field is not null/empty", - "required": [ - "operator" - ], - "properties": { - "operator": { - "type": "string", - "enum": [ - "isNotNull" - ] - } - } + "coverArtistLock": { + "type": "boolean", + "description": "Whether cover artist is locked", + "example": false }, - { - "type": "object", - "description": "String contains (case-insensitive)", - "required": [ - "value", - "operator" - ], - "properties": { - "operator": { - "type": "string", - "enum": [ - "contains" - ] - }, - "value": { - "type": "string" - } - } + "dayLock": { + "type": "boolean", + "description": "Whether day is locked", + "example": false }, - { - "type": "object", - "description": "String does not contain (case-insensitive)", - "required": [ - "value", - "operator" - ], - "properties": { - "operator": { - "type": "string", - "enum": [ - "doesNotContain" - ] - }, - "value": { - "type": "string" - } - } + "editorLock": { + "type": "boolean", + "description": "Whether editor is locked", + "example": false }, - { - "type": "object", - "description": "String starts with (case-insensitive)", - "required": [ - "value", - "operator" - ], - "properties": { - "operator": { - "type": "string", - "enum": [ - "beginsWith" - ] - }, - "value": { - "type": "string" - } - } + "formatDetailLock": { + "type": "boolean", + "description": "Whether format_detail is locked", + "example": false }, - { - "type": "object", - "description": "String ends with (case-insensitive)", - "required": [ - "value", - "operator" - ], - "properties": { - "operator": { - "type": "string", - "enum": [ - "endsWith" - ] - }, - "value": { - "type": "string" - } - } - } - ], - "description": "Operators for string and equality comparisons" - }, - "FileSystemEntry": { - "type": "object", - "required": [ - "name", - "path", - "is_directory", - "is_readable" - ], - "properties": { - "is_directory": { + "genreLock": { "type": "boolean", - "description": "Whether this is a directory" + "description": "Whether genre is locked", + "example": false }, - "is_readable": { + "imprintLock": { "type": "boolean", - "description": "Whether the entry is readable" + "description": "Whether imprint is locked", + "example": false }, - "name": { - "type": "string", - "description": "Name of the file or directory" + "inkerLock": { + "type": "boolean", + "description": "Whether inker is locked", + "example": false }, - "path": { - "type": "string", - "description": "Full path to the entry" - } - }, - "example": { - "is_directory": true, - "is_readable": true, - "name": "Documents", - "path": "/home/user/Documents" - } - }, - "FlatStrategyConfig": { - "type": "object", - "description": "Configuration for flat scanning strategy", - "properties": { - "filenamePatterns": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Regex patterns for extracting series name from filename\nPatterns are tried in order, first match wins" + "isbnsLock": { + "type": "boolean", + "description": "Whether isbns is locked", + "example": false }, - "requireMetadata": { + "languageIsoLock": { "type": "boolean", - "description": "If true, require metadata for series detection (no filename fallback)" - } - } - }, - "ForceRequest": { - "type": "object", - "properties": { - "force": { + "description": "Whether language_iso is locked", + "example": false + }, + "lettererLock": { "type": "boolean", - "description": "If true, regenerate thumbnails even if they exist. If false (default), only generate missing thumbnails.", + "description": "Whether letterer is locked", + "example": false + }, + "mangaLock": { + "type": "boolean", + "description": "Whether manga is locked", + "example": false + }, + "monthLock": { + "type": "boolean", + "description": "Whether month is locked", + "example": false + }, + "pencillerLock": { + "type": "boolean", + "description": "Whether penciller is locked", + "example": false + }, + "publisherLock": { + "type": "boolean", + "description": "Whether publisher is locked", + "example": true + }, + "summaryLock": { + "type": "boolean", + "description": "Whether summary is locked", + "example": false + }, + "volumeLock": { + "type": "boolean", + "description": "Whether volume is locked", + "example": false + }, + "webLock": { + "type": "boolean", + "description": "Whether web URL is locked", + "example": false + }, + "writerLock": { + "type": "boolean", + "description": "Whether writer is locked", "example": false + }, + "yearLock": { + "type": "boolean", + "description": "Whether year is locked", + "example": true } } }, - "FullBookResponse": { + "BookMetadataResponse": { "type": "object", - "description": "Full book response including book data and complete metadata with locks", + "description": "Response containing book metadata", "required": [ - "id", - "libraryId", - "libraryName", - "seriesId", - "seriesName", - "title", - "filePath", - "fileFormat", - "fileSize", - "fileHash", - "pageCount", - "deleted", - "metadata", - "createdAt", + "bookId", "updatedAt" ], "properties": { - "analysisError": { + "blackAndWhite": { "type": [ - "string", + "boolean", "null" ], - "description": "Error message if book analysis failed", - "example": "Failed to parse CBZ: invalid archive" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the book was added to the library", - "example": "2024-01-01T00:00:00Z" - }, - "deleted": { - "type": "boolean", - "description": "Whether the book has been soft-deleted", + "description": "Whether the book is black and white", "example": false }, - "fileFormat": { + "bookId": { "type": "string", - "description": "File format (cbz, cbr, epub, pdf)", - "example": "cbz" + "format": "uuid", + "description": "Book ID", + "example": "550e8400-e29b-41d4-a716-446655440001" }, - "fileHash": { - "type": "string", - "description": "File hash for deduplication", - "example": "a1b2c3d4e5f6g7h8i9j0" - }, - "filePath": { - "type": "string", - "description": "Filesystem path to the book file", - "example": "/media/comics/Batman/Batman - Year One 001.cbz" - }, - "fileSize": { - "type": "integer", - "format": "int64", - "description": "File size in bytes", - "example": 52428800 - }, - "id": { - "type": "string", - "format": "uuid", - "description": "Book unique identifier", - "example": "550e8400-e29b-41d4-a716-446655440001" - }, - "libraryId": { - "type": "string", - "format": "uuid", - "description": "Library this book belongs to", - "example": "550e8400-e29b-41d4-a716-446655440000" - }, - "libraryName": { - "type": "string", - "description": "Name of the library", - "example": "Comics" - }, - "metadata": { - "$ref": "#/components/schemas/BookFullMetadata", - "description": "Complete book metadata with lock states" - }, - "number": { + "colorist": { "type": [ - "integer", + "string", "null" ], - "format": "int32", - "description": "Book number within the series", - "example": 1 - }, - "pageCount": { - "type": "integer", - "format": "int32", - "description": "Number of pages in the book", - "example": 32 - }, - "readProgress": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ReadProgressResponse", - "description": "User's read progress for this book" - } - ] + "description": "Colorist(s)", + "example": "Richmond Lewis" }, - "readingDirection": { + "count": { "type": [ - "string", + "integer", "null" ], - "description": "Effective reading direction (from series metadata, or library default)", - "example": "ltr" - }, - "seriesId": { - "type": "string", - "format": "uuid", - "description": "Series this book belongs to", - "example": "550e8400-e29b-41d4-a716-446655440002" - }, - "seriesName": { - "type": "string", - "description": "Name of the series", - "example": "Batman: Year One" - }, - "title": { - "type": "string", - "description": "Book title (display name)", - "example": "Batman: Year One #1" + "format": "int32", + "description": "Total count in series", + "example": 4 }, - "titleSort": { + "coverArtist": { "type": [ "string", "null" ], - "description": "Title used for sorting", - "example": "batman year one 001" + "description": "Cover artist(s)", + "example": "David Mazzucchelli" }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the book was last updated", - "example": "2024-01-15T10:30:00Z" - } - } - }, - "FullSeriesMetadataResponse": { - "type": "object", - "description": "Full series metadata response including all related data", - "required": [ - "seriesId", - "title", - "locks", - "genres", - "tags", - "alternateTitles", - "externalRatings", - "externalLinks", - "createdAt", - "updatedAt" - ], - "properties": { - "ageRating": { + "day": { "type": [ "integer", "null" ], "format": "int32", - "description": "Age rating (e.g., 13, 16, 18)", - "example": 16 - }, - "alternateTitles": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AlternateTitleDto" - }, - "description": "Alternate titles for this series" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "Timestamps", - "example": "2024-01-01T00:00:00Z" + "description": "Publication day (1-31)", + "example": 1 }, - "customMetadata": { + "editor": { "type": [ - "object", + "string", "null" ], - "description": "Custom JSON metadata" - }, - "externalLinks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ExternalLinkDto" - }, - "description": "External links to other sites" - }, - "externalRatings": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ExternalRatingDto" - }, - "description": "External ratings from various sources" - }, - "genres": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GenreDto" - }, - "description": "Genres assigned to this series" + "description": "Editor(s)", + "example": "Dennis O'Neil" }, - "imprint": { + "formatDetail": { "type": [ "string", "null" ], - "description": "Imprint (sub-publisher)", - "example": "Vertigo" + "description": "Format details", + "example": "Trade Paperback" }, - "language": { + "genre": { "type": [ "string", "null" ], - "description": "Language (BCP47 format: \"en\", \"ja\", \"ko\")", - "example": "en" - }, - "locks": { - "$ref": "#/components/schemas/MetadataLocks", - "description": "Lock states for all metadata fields" + "description": "Genre", + "example": "Superhero" }, - "publisher": { + "imprint": { "type": [ "string", "null" ], - "description": "Publisher name", - "example": "DC Comics" + "description": "Imprint name", + "example": "DC Black Label" }, - "readingDirection": { + "inker": { "type": [ "string", "null" ], - "description": "Reading direction (ltr, rtl, ttb or webtoon)", - "example": "ltr" - }, - "seriesId": { - "type": "string", - "format": "uuid", - "description": "Series ID", - "example": "550e8400-e29b-41d4-a716-446655440002" + "description": "Inker(s)", + "example": "David Mazzucchelli" }, - "status": { + "isbns": { "type": [ "string", "null" ], - "description": "Series status (ongoing, ended, hiatus, abandoned, unknown)", - "example": "ended" + "description": "ISBN(s)", + "example": "978-1401207526" }, - "summary": { + "languageIso": { "type": [ "string", "null" ], - "description": "Series description/summary", - "example": "The definitive origin story of Batman." - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TagDto" - }, - "description": "Tags assigned to this series" - }, - "title": { - "type": "string", - "description": "Series title (usually same as series name)", - "example": "Batman: Year One" + "description": "ISO language code", + "example": "en" }, - "titleSort": { + "letterer": { "type": [ "string", "null" ], - "description": "Custom sort name for ordering", - "example": "Batman Year One" + "description": "Letterer(s)", + "example": "Todd Klein" }, - "totalBookCount": { + "manga": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the book is manga format", + "example": false + }, + "month": { "type": [ "integer", "null" ], "format": "int32", - "description": "Expected total book count (for ongoing series)", - "example": 4 + "description": "Publication month (1-12)", + "example": 2 }, - "updatedAt": { - "type": "string", - "format": "date-time", - "example": "2024-01-15T10:30:00Z" + "penciller": { + "type": [ + "string", + "null" + ], + "description": "Penciller(s)", + "example": "David Mazzucchelli" }, - "year": { + "publisher": { "type": [ - "integer", + "string", "null" ], - "format": "int32", - "description": "Release year", - "example": 1987 - } - } - }, - "FullSeriesResponse": { - "type": "object", - "description": "Full series response including series data and complete metadata", - "required": [ - "id", - "libraryId", - "libraryName", - "bookCount", - "metadata", - "genres", - "tags", - "alternateTitles", - "externalRatings", - "externalLinks", - "createdAt", - "updatedAt" - ], - "properties": { - "alternateTitles": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AlternateTitleDto" - }, - "description": "Alternate titles for this series" - }, - "bookCount": { - "type": "integer", - "format": "int64", - "description": "Total number of books in this series", - "example": 4 - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the series was created", - "example": "2024-01-01T00:00:00Z" - }, - "externalLinks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ExternalLinkDto" - }, - "description": "External links to other sites" - }, - "externalRatings": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ExternalRatingDto" - }, - "description": "External ratings from various sources" - }, - "genres": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GenreDto" - }, - "description": "Genres assigned to this series" + "description": "Publisher name", + "example": "DC Comics" }, - "hasCustomCover": { + "summary": { "type": [ - "boolean", + "string", "null" ], - "description": "Whether the series has a custom cover uploaded", - "example": false - }, - "id": { - "type": "string", - "format": "uuid", - "description": "Series unique identifier", - "example": "550e8400-e29b-41d4-a716-446655440002" - }, - "libraryId": { - "type": "string", - "format": "uuid", - "description": "Library unique identifier", - "example": "550e8400-e29b-41d4-a716-446655440000" + "description": "Book summary/description", + "example": "Bruce Wayne returns to Gotham City." }, - "libraryName": { + "updatedAt": { "type": "string", - "description": "Name of the library this series belongs to", - "example": "Comics" - }, - "metadata": { - "$ref": "#/components/schemas/SeriesFullMetadata", - "description": "Complete series metadata" + "format": "date-time", + "description": "Last update timestamp", + "example": "2024-01-15T10:30:00Z" }, - "path": { + "volume": { "type": [ - "string", + "integer", "null" ], - "description": "Filesystem path to the series directory", - "example": "/media/comics/Batman - Year One" + "format": "int32", + "description": "Volume number", + "example": 1 }, - "selectedCoverSource": { + "web": { "type": [ "string", "null" ], - "description": "Selected cover source (e.g., \"first_book\", \"custom\")", - "example": "first_book" - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TagDto" - }, - "description": "Tags assigned to this series" - }, - "unreadCount": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "description": "Number of unread books in this series (user-specific)", - "example": 2 - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the series was last updated", - "example": "2024-01-15T10:30:00Z" - } - } - }, - "GenerateThumbnailsRequest": { - "type": "object", - "properties": { - "force": { - "type": "boolean", - "description": "If true, regenerate all thumbnails even if they exist. If false (default), only generate missing thumbnails.", - "example": false + "description": "Web URL", + "example": "https://dc.com/batman-year-one" }, - "library_id": { + "writer": { "type": [ "string", "null" ], - "format": "uuid", - "description": "Library ID to generate thumbnails for (optional)", - "example": "550e8400-e29b-41d4-a716-446655440000" + "description": "Writer(s)", + "example": "Frank Miller" }, - "series_id": { + "year": { "type": [ - "string", + "integer", "null" ], - "format": "uuid", - "description": "Series ID to generate thumbnails for (optional, takes precedence over library_id)", - "example": "550e8400-e29b-41d4-a716-446655440001" + "format": "int32", + "description": "Publication year", + "example": 1987 } } }, - "GenreDto": { + "BookStrategy": { + "type": "string", + "description": "Book naming strategy type for determining book titles\n\nDetermines how individual book titles and numbers are resolved.", + "enum": [ + "filename", + "metadata_first", + "smart", + "series_name", + "custom" + ] + }, + "BookUpdateResponse": { "type": "object", - "description": "Genre data transfer object", + "description": "Response for book update", "required": [ "id", - "name", - "createdAt" + "updatedAt" ], "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the genre was created", - "example": "2024-01-01T00:00:00Z" - }, "id": { "type": "string", "format": "uuid", - "description": "Genre ID", - "example": "550e8400-e29b-41d4-a716-446655440010" + "description": "Book ID", + "example": "550e8400-e29b-41d4-a716-446655440001" }, - "name": { - "type": "string", - "description": "Genre name", - "example": "Action" + "number": { + "type": [ + "number", + "null" + ], + "format": "double", + "description": "Updated number", + "example": 1.5 }, - "seriesCount": { + "title": { "type": [ - "integer", + "string", "null" ], - "format": "int64", - "description": "Number of series with this genre", - "example": 42, - "minimum": 0 + "description": "Updated title", + "example": "Chapter 1: The Beginning" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Last update timestamp", + "example": "2024-01-15T10:30:00Z" } } }, - "GenreListResponse": { + "BookWithErrorsDto": { "type": "object", - "description": "Response containing a list of genres", + "description": "A book with its associated errors", "required": [ - "genres" + "book", + "errors" ], "properties": { - "genres": { + "book": { + "$ref": "#/components/schemas/BookDto", + "description": "The book data" + }, + "errors": { "type": "array", "items": { - "$ref": "#/components/schemas/GenreDto" + "$ref": "#/components/schemas/BookErrorDto" }, - "description": "List of genres" + "description": "All errors for this book" } } }, - "Group": { + "BooksPaginationQuery": { "type": "object", - "description": "A group containing navigation or publications\n\nGroups allow organizing multiple collections within a single feed.", - "required": [ - "metadata" - ], + "description": "Query parameters for paginated book endpoints", "properties": { - "metadata": { - "$ref": "#/components/schemas/FeedMetadata", - "description": "Group metadata (title required)" + "page": { + "type": "integer", + "format": "int32", + "description": "Page number (0-indexed, Komga-style)" }, - "navigation": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/components/schemas/Opds2Link" - }, - "description": "Navigation links within this group" + "size": { + "type": "integer", + "format": "int32", + "description": "Page size (default: 20)" }, - "publications": { + "sort": { "type": [ - "array", + "string", "null" ], - "items": { - "$ref": "#/components/schemas/Publication" - }, - "description": "Publications within this group" + "description": "Sort parameter (e.g., \"createdDate,desc\", \"metadata.numberSort,asc\")" } } }, - "ImageLink": { + "BooksWithErrorsResponse": { "type": "object", - "description": "Image link with optional dimensions\n\nUsed for cover images and thumbnails in publications.", + "description": "Response for listing books with errors", "required": [ - "href", - "type" + "totalBooksWithErrors", + "errorCounts", + "groups", + "page", + "pageSize", + "totalPages" ], "properties": { - "height": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Height in pixels" + "errorCounts": { + "type": "object", + "description": "Count of books by error type", + "additionalProperties": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "propertyNames": { + "type": "string" + }, + "example": { + "parser": 5, + "thumbnail": 10 + } }, - "href": { - "type": "string", - "description": "URL to the image" + "groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ErrorGroupDto" + }, + "description": "Error groups with books" }, - "type": { - "type": "string", - "description": "Media type of the image" + "page": { + "type": "integer", + "format": "int64", + "description": "Current page (0-indexed)", + "example": 0, + "minimum": 0 }, - "width": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Width in pixels" + "pageSize": { + "type": "integer", + "format": "int64", + "description": "Page size", + "example": 20, + "minimum": 0 + }, + "totalBooksWithErrors": { + "type": "integer", + "format": "int64", + "description": "Total number of books with errors", + "example": 15, + "minimum": 0 + }, + "totalPages": { + "type": "integer", + "format": "int64", + "description": "Total number of pages", + "example": 1, + "minimum": 0 } } }, - "InitializeSetupRequest": { + "BoolOperator": { + "oneOf": [ + { + "type": "object", + "required": [ + "operator" + ], + "properties": { + "operator": { + "type": "string", + "enum": [ + "isTrue" + ] + } + } + }, + { + "type": "object", + "required": [ + "operator" + ], + "properties": { + "operator": { + "type": "string", + "enum": [ + "isFalse" + ] + } + } + } + ], + "description": "Operators for boolean comparisons" + }, + "BrandingSettingsDto": { "type": "object", - "description": "Initialize setup request - creates first admin user", + "description": "Branding settings DTO (unauthenticated access)\n\nContains branding-related settings that can be accessed without authentication.\nUsed on the login page and other unauthenticated UI surfaces.", "required": [ - "username", - "email", - "password" + "application_name" ], "properties": { - "email": { - "type": "string", - "description": "Email address for the first admin user" - }, - "password": { - "type": "string", - "description": "Password for the first admin user" - }, - "username": { + "application_name": { "type": "string", - "description": "Username for the first admin user" + "description": "The application name to display", + "example": "Codex" } } }, - "InitializeSetupResponse": { + "BrowseResponse": { "type": "object", - "description": "Initialize setup response - returns user and JWT token", "required": [ - "user", - "accessToken", - "tokenType", - "expiresIn", - "message" + "current_path", + "entries" ], "properties": { - "accessToken": { - "type": "string", - "description": "JWT access token" - }, - "expiresIn": { - "type": "integer", - "format": "int64", - "description": "Token expiry in seconds", - "minimum": 0 - }, - "message": { + "current_path": { "type": "string", - "description": "Success message" + "description": "Current directory path" }, - "tokenType": { - "type": "string", - "description": "Token type (always \"Bearer\")" + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileSystemEntry" + }, + "description": "List of entries in the current directory" }, - "user": { - "$ref": "#/components/schemas/UserInfo", - "description": "Created user information" + "parent_path": { + "type": [ + "string", + "null" + ], + "description": "Parent directory path (None if at root)" } + }, + "example": { + "current_path": "/home/user/Documents", + "entries": [ + { + "is_directory": true, + "is_readable": true, + "name": "Comics", + "path": "/home/user/Documents/Comics" + }, + { + "is_directory": true, + "is_readable": true, + "name": "Manga", + "path": "/home/user/Documents/Manga" + } + ], + "parent_path": "/home/user" } }, - "KomgaAgeRestrictionDto": { + "BulkAnalyzeBooksRequest": { "type": "object", - "description": "Komga age restriction DTO", + "description": "Request to perform bulk analyze operations on multiple books", "required": [ - "age", - "restriction" + "bookIds" ], "properties": { - "age": { - "type": "integer", - "format": "int32", - "description": "Age limit" + "bookIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "List of book IDs to analyze", + "example": [ + "550e8400-e29b-41d4-a716-446655440001", + "550e8400-e29b-41d4-a716-446655440002" + ] }, - "restriction": { - "type": "string", - "description": "Restriction type (ALLOW_ONLY, EXCLUDE)" + "force": { + "type": "boolean", + "description": "Whether to force re-analysis of already analyzed books", + "example": false } } }, - "KomgaAlternateTitleDto": { + "BulkAnalyzeResponse": { "type": "object", - "description": "Komga alternate title DTO", + "description": "Response for bulk analyze operations", "required": [ - "label", - "title" + "tasksEnqueued", + "message" ], "properties": { - "label": { + "message": { "type": "string", - "description": "Title label (e.g., \"Japanese\", \"Romaji\")" + "description": "Message describing the operation", + "example": "Enqueued 5 analysis tasks" }, - "title": { - "type": "string", - "description": "The alternate title text" + "tasksEnqueued": { + "type": "integer", + "description": "Number of analysis tasks enqueued", + "example": 5, + "minimum": 0 } } }, - "KomgaAuthorDto": { + "BulkAnalyzeSeriesRequest": { "type": "object", - "description": "Komga author DTO", + "description": "Request to perform bulk analyze operations on multiple series", "required": [ - "name", - "role" + "seriesIds" ], "properties": { - "name": { - "type": "string", - "description": "Author name" + "force": { + "type": "boolean", + "description": "Whether to force re-analysis of already analyzed books", + "example": false }, - "role": { - "type": "string", - "description": "Author role (WRITER, PENCILLER, INKER, COLORIST, LETTERER, COVER, EDITOR)" + "seriesIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "List of series IDs to analyze", + "example": [ + "550e8400-e29b-41d4-a716-446655440001", + "550e8400-e29b-41d4-a716-446655440002" + ] } } }, - "KomgaBookDto": { + "BulkBooksRequest": { "type": "object", - "description": "Komga book DTO\n\nBased on actual Komic traffic analysis. This is the main book representation.", + "description": "Request to perform bulk operations on multiple books", "required": [ - "id", - "seriesId", - "seriesTitle", - "libraryId", - "name", - "url", - "number", - "created", - "lastModified", - "fileLastModified", - "sizeBytes", - "size", - "media", - "metadata" + "bookIds" ], "properties": { - "created": { - "type": "string", - "description": "Created timestamp (ISO 8601)" - }, - "deleted": { - "type": "boolean", - "description": "Whether book is deleted (soft delete)" - }, - "fileHash": { - "type": "string", - "description": "File hash" - }, - "fileLastModified": { - "type": "string", - "description": "File last modified timestamp (ISO 8601)" - }, - "id": { - "type": "string", - "description": "Book unique identifier (UUID as string)" - }, - "lastModified": { - "type": "string", - "description": "Last modified timestamp (ISO 8601)" - }, - "libraryId": { - "type": "string", - "description": "Library ID" - }, - "media": { - "$ref": "#/components/schemas/KomgaMediaDto", - "description": "Media information" - }, - "metadata": { - "$ref": "#/components/schemas/KomgaBookMetadataDto", - "description": "Book metadata" - }, - "name": { - "type": "string", - "description": "Book filename/name" - }, - "number": { - "type": "integer", - "format": "int32", - "description": "Book number in series" - }, - "oneshot": { - "type": "boolean", - "description": "Whether this is a oneshot" - }, - "readProgress": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/KomgaReadProgressDto", - "description": "User's read progress (null if not started)" - } + "bookIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "List of book IDs to operate on", + "example": [ + "550e8400-e29b-41d4-a716-446655440001", + "550e8400-e29b-41d4-a716-446655440002" ] - }, - "seriesId": { - "type": "string", - "description": "Series ID" - }, - "seriesTitle": { - "type": "string", - "description": "Series title (required by Komic for display)" - }, - "size": { - "type": "string", - "description": "Human-readable file size (e.g., \"869.9 MiB\")" - }, - "sizeBytes": { - "type": "integer", - "format": "int64", - "description": "File size in bytes" - }, - "url": { - "type": "string", - "description": "File URL/path" } } }, - "KomgaBookLinkDto": { + "BulkSeriesRequest": { "type": "object", - "description": "Komga book link DTO", + "description": "Request to perform bulk operations on multiple series", "required": [ - "label", - "url" + "seriesIds" ], "properties": { - "label": { + "seriesIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "List of series IDs to operate on", + "example": [ + "550e8400-e29b-41d4-a716-446655440001", + "550e8400-e29b-41d4-a716-446655440002" + ] + } + } + }, + "BulkSetPreferencesRequest": { + "type": "object", + "description": "Request to set multiple preferences at once", + "required": [ + "preferences" + ], + "properties": { + "preferences": { + "type": "object", + "description": "Map of preference keys to values", + "additionalProperties": {}, + "propertyNames": { + "type": "string" + }, + "example": { + "reader.zoom": 150, + "ui.theme": "dark" + } + } + } + }, + "BulkSettingUpdate": { + "type": "object", + "description": "Single setting update in a bulk operation", + "required": [ + "key", + "value" + ], + "properties": { + "key": { "type": "string", - "description": "Link label" + "description": "Setting key to update", + "example": "scan.concurrent_jobs" }, - "url": { + "value": { "type": "string", - "description": "Link URL" + "description": "New value for the setting", + "example": "4" } } }, - "KomgaBookMetadataDto": { + "BulkUpdateSettingsRequest": { "type": "object", - "description": "Komga book metadata DTO", + "description": "Bulk update settings request", "required": [ - "title", - "number", - "numberSort", - "created", - "lastModified" + "updates" ], "properties": { - "authors": { + "change_reason": { + "type": [ + "string", + "null" + ], + "description": "Optional reason for the changes (for audit log)", + "example": "Batch configuration update for production" + }, + "updates": { "type": "array", "items": { - "$ref": "#/components/schemas/KomgaAuthorDto" + "$ref": "#/components/schemas/BulkSettingUpdate" }, - "description": "Authors list" - }, - "authorsLock": { + "description": "List of settings to update" + } + } + }, + "CalibreSeriesMode": { + "type": "string", + "description": "How Calibre strategy groups books into series", + "enum": [ + "standalone", + "by_author", + "from_metadata" + ] + }, + "CalibreStrategyConfig": { + "type": "object", + "description": "Configuration for Calibre strategy", + "properties": { + "authorFromFolder": { "type": "boolean", - "description": "Whether authors are locked" + "description": "Use author folder name as author metadata" }, - "created": { - "type": "string", - "description": "Created timestamp (ISO 8601)" + "readOpfMetadata": { + "type": "boolean", + "description": "Read metadata.opf files for rich metadata" }, - "isbn": { - "type": "string", - "description": "ISBN" + "seriesMode": { + "$ref": "#/components/schemas/CalibreSeriesMode", + "description": "How to group books into series" }, - "isbnLock": { + "stripIdSuffix": { "type": "boolean", - "description": "Whether ISBN is locked" + "description": "Strip Calibre ID suffix from folder names (e.g., \" (123)\")" + } + } + }, + "CleanupResultDto": { + "type": "object", + "description": "Result of a cleanup operation", + "required": [ + "thumbnails_deleted", + "covers_deleted", + "bytes_freed", + "failures" + ], + "properties": { + "bytes_freed": { + "type": "integer", + "format": "int64", + "description": "Total bytes freed by deletion", + "example": 1073741824, + "minimum": 0 }, - "lastModified": { - "type": "string", - "description": "Last modified timestamp (ISO 8601)" + "covers_deleted": { + "type": "integer", + "format": "int32", + "description": "Number of cover files deleted", + "example": 5, + "minimum": 0 }, - "links": { + "errors": { "type": "array", "items": { - "$ref": "#/components/schemas/KomgaBookLinkDto" + "type": "string" }, - "description": "Links" - }, - "linksLock": { - "type": "boolean", - "description": "Whether links are locked" + "description": "Error messages for any failed deletions" }, - "number": { - "type": "string", - "description": "Book number (display string)" - }, - "numberLock": { - "type": "boolean", - "description": "Whether number is locked" - }, - "numberSort": { - "type": "number", - "format": "double", - "description": "Number for sorting (float for chapter ordering)" - }, - "numberSortLock": { - "type": "boolean", - "description": "Whether number_sort is locked" - }, - "releaseDate": { - "type": [ - "string", - "null" - ], - "description": "Release date (YYYY-MM-DD or full ISO 8601)" - }, - "releaseDateLock": { - "type": "boolean", - "description": "Whether release_date is locked" - }, - "summary": { - "type": "string", - "description": "Book summary" - }, - "summaryLock": { - "type": "boolean", - "description": "Whether summary is locked" + "failures": { + "type": "integer", + "format": "int32", + "description": "Number of files that failed to delete", + "example": 0, + "minimum": 0 }, - "tags": { - "type": "array", - "items": { + "thumbnails_deleted": { + "type": "integer", + "format": "int32", + "description": "Number of thumbnail files deleted", + "example": 42, + "minimum": 0 + } + } + }, + "ConfigureSettingsRequest": { + "type": "object", + "description": "Configure initial settings request", + "required": [ + "settings", + "skipConfiguration" + ], + "properties": { + "settings": { + "type": "object", + "description": "Settings to configure (key-value pairs)", + "additionalProperties": { "type": "string" }, - "description": "Tags list" - }, - "tagsLock": { - "type": "boolean", - "description": "Whether tags are locked" - }, - "title": { - "type": "string", - "description": "Book title" + "propertyNames": { + "type": "string" + } }, - "titleLock": { + "skipConfiguration": { "type": "boolean", - "description": "Whether title is locked" + "description": "Whether to skip settings configuration" } } }, - "KomgaBooksMetadataAggregationDto": { + "ConfigureSettingsResponse": { "type": "object", - "description": "Komga books metadata aggregation DTO\n\nAggregated metadata from all books in the series.", + "description": "Configure settings response", "required": [ - "created", - "lastModified" + "message", + "settingsConfigured" ], "properties": { - "authors": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KomgaAuthorDto" - }, - "description": "Authors from all books" - }, - "created": { + "message": { "type": "string", - "description": "Created timestamp (ISO 8601)" + "description": "Success message" }, - "lastModified": { + "settingsConfigured": { + "type": "integer", + "description": "Number of settings configured", + "minimum": 0 + } + } + }, + "Contributor": { + "type": "object", + "description": "Contributor information (author, artist, etc.)", + "required": [ + "name" + ], + "properties": { + "name": { "type": "string", - "description": "Last modified timestamp (ISO 8601)" + "description": "Name of the contributor" }, - "releaseDate": { + "sortAs": { "type": [ "string", "null" ], - "description": "Release date range (earliest)" - }, - "summary": { + "description": "Sort-friendly version of the name" + } + } + }, + "CreateAlternateTitleRequest": { + "type": "object", + "description": "Request to create an alternate title for a series", + "required": [ + "label", + "title" + ], + "properties": { + "label": { "type": "string", - "description": "Summary (from first book or series)" + "description": "Label for this title (e.g., \"Japanese\", \"Romaji\", \"English\")", + "example": "Japanese" }, - "summaryNumber": { + "title": { "type": "string", - "description": "Summary number (if multiple summaries)" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Tags from all books" + "description": "The alternate title", + "example": "進撃の巨人" } } }, - "KomgaBooksSearchRequestDto": { + "CreateApiKeyRequest": { "type": "object", - "description": "Request DTO for searching/filtering books (POST /api/v1/books/list)", + "description": "Create API key request", + "required": [ + "name" + ], "properties": { - "author": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - }, - "description": "Authors filter" - }, - "condition": { - "description": "Condition object for complex queries (used by Komic for readStatus filtering)" - }, - "deleted": { - "type": [ - "boolean", - "null" - ], - "description": "Deleted filter" - }, - "fullTextSearch": { + "expiresAt": { "type": [ "string", "null" ], - "description": "Full text search query" + "format": "date-time", + "description": "Optional expiration date", + "example": "2025-12-31T23:59:59Z" }, - "libraryId": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - }, - "description": "Library IDs to filter by" + "name": { + "type": "string", + "description": "Name/description for the API key", + "example": "Mobile App Key" }, - "mediaStatus": { + "permissions": { "type": [ "array", "null" @@ -15602,1907 +15014,5264 @@ "items": { "type": "string" }, - "description": "Media status filter" + "description": "Permissions for the API key (array of permission strings)\nIf not provided, uses the user's current permissions" + } + } + }, + "CreateApiKeyResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiKeyDto" }, - "readStatus": { - "type": [ - "array", - "null" + { + "type": "object", + "required": [ + "key" ], - "items": { - "type": "string" - }, - "description": "Read status filter" - }, - "searchTerm": { + "properties": { + "key": { + "type": "string", + "description": "The plaintext API key (only shown once on creation)", + "example": "cdx_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" + } + } + } + ], + "description": "API key creation response (includes plaintext key only on creation)" + }, + "CreateExternalLinkRequest": { + "type": "object", + "description": "Request to create or update an external link for a series", + "required": [ + "sourceName", + "url" + ], + "properties": { + "externalId": { "type": [ "string", "null" ], - "description": "Search term" + "description": "ID on the external source (if available)", + "example": "12345" }, - "seriesId": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - }, - "description": "Series IDs to filter by" + "sourceName": { + "type": "string", + "description": "Source name (e.g., \"myanimelist\", \"anilist\", \"mangadex\")\nWill be normalized to lowercase", + "example": "myanimelist" }, - "tag": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - }, - "description": "Tags filter" + "url": { + "type": "string", + "description": "URL to the external source", + "example": "https://myanimelist.net/manga/12345" } } }, - "KomgaContentRestrictionsDto": { + "CreateExternalRatingRequest": { "type": "object", - "description": "Komga content restrictions DTO", + "description": "Request to create or update an external rating for a series", + "required": [ + "sourceName", + "rating" + ], "properties": { - "ageRestriction": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/KomgaAgeRestrictionDto", - "description": "Age restriction (null means no restriction)" - } - ] + "rating": { + "type": "number", + "format": "double", + "description": "Rating value (0-100)", + "example": 85.5 }, - "labelsAllow": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Labels restriction" + "sourceName": { + "type": "string", + "description": "Source name (e.g., \"myanimelist\", \"anilist\", \"mangabaka\")\nWill be normalized to lowercase", + "example": "myanimelist" }, - "labelsExclude": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Labels to exclude" + "voteCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Number of votes (if available)", + "example": 12500 } } }, - "KomgaLibraryDto": { + "CreateLibraryRequest": { "type": "object", - "description": "Komga library DTO\n\nBased on actual Komic traffic analysis - includes all fields observed in responses.", + "description": "Create library request", "required": [ - "id", "name", - "root" + "path" ], "properties": { - "analyzeDimensions": { - "type": "boolean", - "description": "Whether to analyze page dimensions" + "allowedFormats": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Allowed file formats (e.g., [\"CBZ\", \"CBR\", \"EPUB\"])", + "example": [ + "CBZ", + "CBR", + "EPUB" + ] }, - "convertToCbz": { - "type": "boolean", - "description": "Whether to convert archives to CBZ" + "bookConfig": { + "description": "Book strategy-specific configuration (JSON, mutable after creation)" }, - "emptyTrashAfterScan": { - "type": "boolean", - "description": "Whether to empty trash after scan" + "bookStrategy": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/BookStrategy", + "description": "Book naming strategy (mutable after creation)\nOptions: filename, metadata_first, smart, series_name" + } + ] }, - "hashFiles": { - "type": "boolean", - "description": "Whether to hash files for deduplication" + "defaultReadingDirection": { + "type": [ + "string", + "null" + ], + "description": "Default reading direction for books in this library (ltr, rtl, ttb or webtoon)", + "example": "ltr" }, - "hashKoreader": { - "type": "boolean", - "description": "Whether to hash files for KOReader sync" + "description": { + "type": [ + "string", + "null" + ], + "description": "Optional description", + "example": "My comic book collection" }, - "hashPages": { - "type": "boolean", - "description": "Whether to hash pages" + "excludedPatterns": { + "type": [ + "string", + "null" + ], + "description": "Excluded path patterns (newline-separated, e.g., \".DS_Store\\nThumbs.db\")", + "example": ".DS_Store\nThumbs.db" }, - "id": { + "name": { "type": "string", - "description": "Library unique identifier (UUID as string)" - }, - "importBarcodeIsbn": { - "type": "boolean", - "description": "Whether to import barcode/ISBN" + "description": "Library name", + "example": "Comics" }, - "importComicInfoBook": { - "type": "boolean", - "description": "Whether to import book info from ComicInfo.xml" + "numberConfig": { + "description": "Number strategy-specific configuration (JSON, mutable after creation)" }, - "importComicInfoCollection": { - "type": "boolean", - "description": "Whether to import collection info from ComicInfo.xml" + "numberStrategy": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NumberStrategy", + "description": "Book number strategy (mutable after creation)\nOptions: file_order, metadata, filename, smart" + } + ] }, - "importComicInfoReadList": { - "type": "boolean", - "description": "Whether to import read list from ComicInfo.xml" + "path": { + "type": "string", + "description": "Filesystem path to the library", + "example": "/media/comics" }, - "importComicInfoSeries": { + "scanImmediately": { "type": "boolean", - "description": "Whether to import series info from ComicInfo.xml" + "description": "Scan immediately after creation (not stored in DB)", + "example": true }, - "importComicInfoSeriesAppendVolume": { - "type": "boolean", - "description": "Whether to append volume to series name from ComicInfo" + "scanningConfig": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ScanningConfigDto", + "description": "Scanning configuration" + } + ] }, - "importEpubBook": { - "type": "boolean", - "description": "Whether to import EPUB book metadata" + "seriesConfig": { + "description": "Strategy-specific configuration (JSON, immutable after creation)" }, - "importEpubSeries": { - "type": "boolean", - "description": "Whether to import EPUB series metadata" + "seriesStrategy": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SeriesStrategy", + "description": "Series detection strategy (immutable after creation)\nOptions: series_volume, series_volume_chapter, flat, publisher_hierarchy, calibre, custom" + } + ] + } + } + }, + "CreatePluginRequest": { + "type": "object", + "description": "Request to create a new plugin", + "required": [ + "name", + "displayName", + "command" + ], + "properties": { + "args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Command arguments", + "example": [ + "/opt/codex/plugins/mangabaka/dist/index.js" + ] }, - "importLocalArtwork": { - "type": "boolean", - "description": "Whether to import local artwork" + "command": { + "type": "string", + "description": "Command to spawn the plugin", + "example": "node" }, - "importMylarSeries": { - "type": "boolean", - "description": "Whether to import Mylar series data" + "config": { + "description": "Plugin-specific configuration" }, - "name": { + "credentialDelivery": { "type": "string", - "description": "Library display name" + "description": "How credentials are delivered to the plugin: \"env\", \"init_message\", or \"both\"", + "example": "env" }, - "oneshotsDirectory": { + "credentials": { + "description": "Credentials (will be encrypted before storage)" + }, + "description": { "type": [ "string", "null" ], - "description": "Directory path for oneshots (optional)" - }, - "repairExtensions": { - "type": "boolean", - "description": "Whether to repair file extensions" + "description": "Description of the plugin", + "example": "Fetch manga metadata from MangaBaka (MangaUpdates)" }, - "root": { + "displayName": { "type": "string", - "description": "Root filesystem path" + "description": "Human-readable display name", + "example": "MangaBaka" }, - "scanCbx": { + "enabled": { "type": "boolean", - "description": "Whether to scan CBZ/CBR files" + "description": "Whether to enable immediately", + "example": false }, - "scanDirectoryExclusions": { + "env": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/EnvVarDto" }, - "description": "Directory exclusion patterns" + "description": "Additional environment variables", + "example": { + "LOG_LEVEL": "info" + } }, - "scanEpub": { - "type": "boolean", - "description": "Whether to scan EPUB files" + "libraryIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Library IDs this plugin applies to (empty = all libraries)", + "example": [] }, - "scanForceModifiedTime": { - "type": "boolean", - "description": "Whether to force modified time for scan" + "name": { + "type": "string", + "description": "Unique identifier (alphanumeric with underscores)", + "example": "mangabaka" }, - "scanInterval": { - "type": "string", - "description": "Scan interval (WEEKLY, DAILY, HOURLY, EVERY_6H, EVERY_12H, DISABLED)" + "permissions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "RBAC permissions for metadata writes", + "example": [ + "metadata:write:summary", + "metadata:write:genres" + ] }, - "scanOnStartup": { - "type": "boolean", - "description": "Whether to scan on startup" + "pluginType": { + "type": "string", + "description": "Plugin type: \"system\" (default) or \"user\"", + "example": "system" }, - "scanPdf": { - "type": "boolean", - "description": "Whether to scan PDF files" + "rateLimitRequestsPerMinute": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Rate limit in requests per minute (default: 60, None = no limit)", + "example": 60 }, - "seriesCover": { - "type": "string", - "description": "Series cover selection strategy (FIRST, FIRST_UNREAD_OR_FIRST, FIRST_UNREAD_OR_LAST, LAST)" + "scopes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Scopes where plugin can be invoked", + "example": [ + "series:detail", + "series:bulk" + ] }, - "unavailable": { - "type": "boolean", - "description": "Whether library is unavailable (path doesn't exist)" + "workingDirectory": { + "type": [ + "string", + "null" + ], + "description": "Working directory for the plugin process" } } }, - "KomgaMediaDto": { + "CreateSharingTagRequest": { "type": "object", - "description": "Komga media DTO\n\nInformation about the book's media/file.", + "description": "Create sharing tag request", "required": [ - "status", - "mediaType", - "mediaProfile", - "pagesCount" + "name" ], "properties": { - "comment": { - "type": "string", - "description": "Comment/notes about media analysis" + "description": { + "type": [ + "string", + "null" + ], + "description": "Optional description", + "example": "Content appropriate for children" }, - "epubDivinaCompatible": { - "type": "boolean", - "description": "Whether EPUB is DIVINA-compatible" + "name": { + "type": "string", + "description": "Display name for the sharing tag (must be unique)", + "example": "Kids Content" + } + } + }, + "CreateTaskRequest": { + "type": "object", + "required": [ + "task_type" + ], + "properties": { + "priority": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Priority level (higher = more urgent)", + "example": 0 }, - "epubIsKepub": { - "type": "boolean", - "description": "Whether EPUB is a KePub file" + "scheduled_for": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When to run the task (defaults to now)", + "example": "2024-01-15T12:00:00Z" }, - "mediaProfile": { + "task_type": { + "$ref": "#/components/schemas/TaskType", + "description": "Type of task to create" + } + } + }, + "CreateTaskResponse": { + "type": "object", + "required": [ + "task_id" + ], + "properties": { + "task_id": { "type": "string", - "description": "Media profile (DIVINA for comics/manga, PDF for PDFs)" + "format": "uuid", + "description": "ID of the created task", + "example": "550e8400-e29b-41d4-a716-446655440000" + } + } + }, + "CreateUserRequest": { + "type": "object", + "description": "Create user request", + "required": [ + "username", + "email", + "password" + ], + "properties": { + "email": { + "type": "string", + "description": "Email address for the new account", + "example": "newuser@example.com" }, - "mediaType": { + "password": { "type": "string", - "description": "MIME type (e.g., \"application/zip\", \"application/epub+zip\", \"application/pdf\")" + "description": "Password for the new account", + "example": "securePassword123!" }, - "pagesCount": { - "type": "integer", - "format": "int32", - "description": "Number of pages" + "role": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/UserRole", + "description": "User role (reader, maintainer, admin). Defaults to reader if not specified." + } + ] }, - "status": { + "username": { "type": "string", - "description": "Media status (READY, UNKNOWN, ERROR, UNSUPPORTED, OUTDATED)" + "description": "Username for the new account", + "example": "newuser" } } }, - "KomgaPageDto": { + "CredentialFieldDto": { "type": "object", - "description": "Komga page DTO\n\nRepresents a single page within a book.\nBased on actual Komic traffic analysis for GET /api/v1/books/{id}/pages", + "description": "Credential field definition", "required": [ - "fileName", - "mediaType", - "number", - "width", - "height", - "sizeBytes", - "size" + "key", + "label", + "credentialType" ], "properties": { - "fileName": { + "credentialType": { "type": "string", - "description": "Original filename within archive" + "description": "Input type for UI" }, - "height": { - "type": "integer", - "format": "int32", - "description": "Image height in pixels" + "description": { + "type": [ + "string", + "null" + ], + "description": "Description for the user" }, - "mediaType": { + "key": { "type": "string", - "description": "MIME type (e.g., \"image/png\", \"image/jpeg\", \"image/webp\")" + "description": "Credential key (e.g., \"api_key\")" }, - "number": { - "type": "integer", - "format": "int32", - "description": "Page number (1-indexed)" + "label": { + "type": "string", + "description": "Display label (e.g., \"API Key\")" }, - "size": { + "required": { + "type": "boolean", + "description": "Whether this credential is required" + }, + "sensitive": { + "type": "boolean", + "description": "Whether to mask the value in UI" + } + } + }, + "CustomStrategyConfig": { + "type": "object", + "description": "Configuration for custom series strategy\n\nNote: Volume/chapter extraction from filenames is handled by the book strategy,\nnot the series strategy. Use CustomBookConfig for regex-based volume/chapter parsing.", + "required": [ + "pattern" + ], + "properties": { + "pattern": { "type": "string", - "description": "Human-readable file size (e.g., \"2.5 MiB\")" + "description": "Regex pattern with named capture groups for series detection\nSupported groups: publisher, series, book\nExample: \"^(?P[^/]+)/(?P[^/]+)/(?P.+)\\\\.(cbz|cbr|epub|pdf)$\"" }, - "sizeBytes": { - "type": "integer", - "format": "int64", - "description": "Page file size in bytes" + "seriesNameTemplate": { + "type": "string", + "description": "Template for constructing series name from capture groups\nExample: \"{publisher} - {series}\"" + } + } + }, + "DeletePreferenceResponse": { + "type": "object", + "description": "Response after deleting a preference", + "required": [ + "deleted", + "message" + ], + "properties": { + "deleted": { + "type": "boolean", + "description": "Whether a preference was deleted", + "example": true }, - "width": { - "type": "integer", - "format": "int32", - "description": "Image width in pixels" + "message": { + "type": "string", + "description": "Message describing the result", + "example": "Preference 'ui.theme' was reset to default" } } }, - "KomgaPage_KomgaBookDto": { + "DetectedSeriesDto": { "type": "object", - "description": "Komga paginated response wrapper (Spring Data Page format)\n\nThis matches the exact structure Komic expects from Komga.", + "description": "Detected series information for preview", "required": [ - "content", - "pageable", - "totalElements", - "totalPages", - "last", - "number", - "size", - "numberOfElements", - "first", - "empty", - "sort" + "name", + "bookCount", + "sampleBooks" ], "properties": { - "content": { - "type": "array", - "items": { - "type": "object", - "description": "Komga book DTO\n\nBased on actual Komic traffic analysis. This is the main book representation.", - "required": [ - "id", - "seriesId", - "seriesTitle", - "libraryId", - "name", - "url", - "number", - "created", - "lastModified", - "fileLastModified", - "sizeBytes", - "size", - "media", - "metadata" - ], - "properties": { - "created": { - "type": "string", - "description": "Created timestamp (ISO 8601)" - }, - "deleted": { - "type": "boolean", - "description": "Whether book is deleted (soft delete)" - }, - "fileHash": { - "type": "string", - "description": "File hash" - }, - "fileLastModified": { - "type": "string", - "description": "File last modified timestamp (ISO 8601)" - }, - "id": { - "type": "string", - "description": "Book unique identifier (UUID as string)" - }, - "lastModified": { - "type": "string", - "description": "Last modified timestamp (ISO 8601)" - }, - "libraryId": { - "type": "string", - "description": "Library ID" - }, - "media": { - "$ref": "#/components/schemas/KomgaMediaDto", - "description": "Media information" - }, - "metadata": { - "$ref": "#/components/schemas/KomgaBookMetadataDto", - "description": "Book metadata" - }, - "name": { - "type": "string", - "description": "Book filename/name" - }, - "number": { - "type": "integer", - "format": "int32", - "description": "Book number in series" - }, - "oneshot": { - "type": "boolean", - "description": "Whether this is a oneshot" - }, - "readProgress": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/KomgaReadProgressDto", - "description": "User's read progress (null if not started)" - } - ] - }, - "seriesId": { - "type": "string", - "description": "Series ID" - }, - "seriesTitle": { - "type": "string", - "description": "Series title (required by Komic for display)" - }, - "size": { - "type": "string", - "description": "Human-readable file size (e.g., \"869.9 MiB\")" - }, - "sizeBytes": { - "type": "integer", - "format": "int64", - "description": "File size in bytes" - }, - "url": { - "type": "string", - "description": "File URL/path" - } - } - }, - "description": "The content items for this page" + "bookCount": { + "type": "integer", + "description": "Number of books detected", + "minimum": 0 }, - "empty": { - "type": "boolean", - "description": "Whether the page is empty" + "metadata": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DetectedSeriesMetadataDto", + "description": "Metadata extracted during detection" + } + ] }, - "first": { - "type": "boolean", - "description": "Whether this is the first page" + "name": { + "type": "string", + "description": "Series name as detected" }, - "last": { - "type": "boolean", - "description": "Whether this is the last page" + "path": { + "type": [ + "string", + "null" + ], + "description": "Path relative to library root" }, - "number": { - "type": "integer", - "format": "int32", - "description": "Current page number (0-indexed)" + "sampleBooks": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Sample book filenames (first 5)" + } + } + }, + "DetectedSeriesMetadataDto": { + "type": "object", + "description": "Metadata extracted during series detection", + "properties": { + "author": { + "type": [ + "string", + "null" + ], + "description": "Author (for calibre strategy)" }, - "numberOfElements": { - "type": "integer", - "format": "int32", - "description": "Number of elements on this page" + "publisher": { + "type": [ + "string", + "null" + ], + "description": "Publisher (for publisher_hierarchy strategy)" + } + } + }, + "DuplicateGroup": { + "type": "object", + "description": "A group of duplicate books", + "required": [ + "id", + "file_hash", + "book_ids", + "duplicate_count", + "created_at", + "updated_at" + ], + "properties": { + "book_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "List of book IDs that share this hash" }, - "pageable": { - "$ref": "#/components/schemas/KomgaPageable", - "description": "Pageable information" + "created_at": { + "type": "string", + "description": "When the duplicate was first detected", + "example": "2024-01-15T10:30:00Z" }, - "size": { + "duplicate_count": { "type": "integer", "format": "int32", - "description": "Page size" + "description": "Number of duplicate copies found", + "example": 3 }, - "sort": { - "$ref": "#/components/schemas/KomgaSort", - "description": "Sort information" + "file_hash": { + "type": "string", + "description": "SHA-256 hash of the file content", + "example": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, - "totalElements": { - "type": "integer", - "format": "int64", - "description": "Total number of elements across all pages" + "id": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for the duplicate group", + "example": "550e8400-e29b-41d4-a716-446655440000" }, - "totalPages": { - "type": "integer", - "format": "int32", - "description": "Total number of pages" + "updated_at": { + "type": "string", + "description": "When the group was last updated", + "example": "2024-01-15T10:30:00Z" } } }, - "KomgaPage_KomgaSeriesDto": { + "EnqueueAutoMatchRequest": { "type": "object", - "description": "Komga paginated response wrapper (Spring Data Page format)\n\nThis matches the exact structure Komic expects from Komga.", + "description": "Request to enqueue plugin auto-match task for a single series", "required": [ - "content", - "pageable", - "totalElements", - "totalPages", - "last", - "number", - "size", - "numberOfElements", - "first", - "empty", - "sort" + "pluginId" ], "properties": { - "content": { + "pluginId": { + "type": "string", + "format": "uuid", + "description": "Plugin ID to use for matching" + } + } + }, + "EnqueueAutoMatchResponse": { + "type": "object", + "description": "Response after enqueuing auto-match task(s)", + "required": [ + "success", + "tasksEnqueued", + "taskIds", + "message" + ], + "properties": { + "message": { + "type": "string", + "description": "Message" + }, + "success": { + "type": "boolean", + "description": "Whether the operation succeeded" + }, + "taskIds": { "type": "array", "items": { - "type": "object", - "description": "Komga series DTO\n\nBased on actual Komic traffic analysis.", - "required": [ - "id", - "libraryId", - "name", - "url", - "created", - "lastModified", - "fileLastModified", - "booksCount", - "booksReadCount", - "booksUnreadCount", - "booksInProgressCount", - "metadata", - "booksMetadata" - ], - "properties": { - "booksCount": { - "type": "integer", - "format": "int32", - "description": "Total books count" - }, - "booksInProgressCount": { - "type": "integer", - "format": "int32", - "description": "In-progress books count" - }, - "booksMetadata": { - "$ref": "#/components/schemas/KomgaBooksMetadataAggregationDto", - "description": "Aggregated books metadata" - }, - "booksReadCount": { - "type": "integer", - "format": "int32", - "description": "Read books count" - }, - "booksUnreadCount": { - "type": "integer", - "format": "int32", - "description": "Unread books count" - }, - "created": { - "type": "string", - "description": "Created timestamp (ISO 8601)" - }, - "deleted": { - "type": "boolean", - "description": "Whether series is deleted (soft delete)" - }, - "fileLastModified": { - "type": "string", - "description": "File last modified timestamp (ISO 8601)" - }, - "id": { - "type": "string", - "description": "Series unique identifier (UUID as string)" - }, - "lastModified": { - "type": "string", - "description": "Last modified timestamp (ISO 8601)" - }, - "libraryId": { - "type": "string", - "description": "Library ID" - }, - "metadata": { - "$ref": "#/components/schemas/KomgaSeriesMetadataDto", - "description": "Series metadata" - }, - "name": { - "type": "string", - "description": "Series name" - }, - "oneshot": { - "type": "boolean", - "description": "Whether this is a oneshot (single book)" - }, - "url": { - "type": "string", - "description": "File URL/path" - } - } + "type": "string", + "format": "uuid" }, - "description": "The content items for this page" - }, - "empty": { - "type": "boolean", - "description": "Whether the page is empty" - }, - "first": { - "type": "boolean", - "description": "Whether this is the first page" - }, - "last": { - "type": "boolean", - "description": "Whether this is the last page" - }, - "number": { - "type": "integer", - "format": "int32", - "description": "Current page number (0-indexed)" + "description": "Task IDs that were created" }, - "numberOfElements": { - "type": "integer", - "format": "int32", - "description": "Number of elements on this page" - }, - "pageable": { - "$ref": "#/components/schemas/KomgaPageable", - "description": "Pageable information" - }, - "size": { - "type": "integer", - "format": "int32", - "description": "Page size" - }, - "sort": { - "$ref": "#/components/schemas/KomgaSort", - "description": "Sort information" - }, - "totalElements": { - "type": "integer", - "format": "int64", - "description": "Total number of elements across all pages" - }, - "totalPages": { + "tasksEnqueued": { "type": "integer", - "format": "int32", - "description": "Total number of pages" + "description": "Number of tasks enqueued", + "minimum": 0 } } }, - "KomgaPageable": { + "EnqueueBulkAutoMatchRequest": { "type": "object", - "description": "Komga pageable information (Spring Data style)", + "description": "Request to enqueue plugin auto-match tasks for multiple series (bulk)", "required": [ - "pageNumber", - "pageSize", - "sort", - "offset", - "paged", - "unpaged" + "pluginId", + "seriesIds" ], "properties": { - "offset": { - "type": "integer", - "format": "int64", - "description": "Offset from start (page_number * page_size)" - }, - "pageNumber": { - "type": "integer", - "format": "int32", - "description": "Current page number (0-indexed)" - }, - "pageSize": { - "type": "integer", - "format": "int32", - "description": "Page size (number of items per page)" - }, - "paged": { - "type": "boolean", - "description": "Whether the pageable is paged (always true for paginated results)" - }, - "sort": { - "$ref": "#/components/schemas/KomgaSort", - "description": "Sort information" + "pluginId": { + "type": "string", + "format": "uuid", + "description": "Plugin ID to use for matching" }, - "unpaged": { - "type": "boolean", - "description": "Whether the pageable is unpaged (always false for paginated results)" + "seriesIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Series IDs to auto-match" } } }, - "KomgaReadProgressDto": { + "EnqueueLibraryAutoMatchRequest": { "type": "object", - "description": "Komga read progress DTO", + "description": "Request to enqueue plugin auto-match tasks for all series in a library", "required": [ - "page", - "completed", - "created", - "lastModified" + "pluginId" ], "properties": { - "completed": { - "type": "boolean", - "description": "Whether the book is completed" - }, - "created": { + "pluginId": { "type": "string", - "description": "Created timestamp (ISO 8601)" - }, - "deviceId": { - "type": "string", - "description": "Device ID that last updated progress" - }, - "deviceName": { - "type": "string", - "description": "Device name that last updated progress" - }, - "lastModified": { - "type": "string", - "description": "Last modified timestamp (ISO 8601)" - }, - "page": { - "type": "integer", - "format": "int32", - "description": "Current page number (1-indexed)" - }, - "readDate": { - "type": [ - "string", - "null" - ], - "description": "When the book was last read (ISO 8601)" + "format": "uuid", + "description": "Plugin ID to use for matching" } } }, - "KomgaReadProgressUpdateDto": { - "type": "object", - "description": "Request DTO for updating read progress\n\nObserved from actual Komic traffic: `{ \"completed\": false, \"page\": 151 }`", - "properties": { - "completed": { - "type": [ - "boolean", - "null" - ], - "description": "Whether book is completed" - }, - "deviceId": { - "type": [ - "string", - "null" - ], - "description": "Device ID (optional, may be used by some clients)" - }, - "deviceName": { - "type": [ - "string", - "null" - ], - "description": "Device name (optional, may be used by some clients)" + "EntityChangeEvent": { + "allOf": [ + { + "$ref": "#/components/schemas/EntityEvent", + "description": "The specific event that occurred" }, - "page": { - "type": [ - "integer", - "null" + { + "type": "object", + "required": [ + "timestamp" ], - "format": "int32", - "description": "Current page number (1-indexed)" + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "description": "When the event occurred" + }, + "user_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "User who triggered the change (if applicable)" + } + } + } + ], + "description": "Complete entity change event with metadata" + }, + "EntityEvent": { + "oneOf": [ + { + "type": "object", + "description": "A book was created", + "required": [ + "book_id", + "series_id", + "library_id", + "type" + ], + "properties": { + "book_id": { + "type": "string", + "format": "uuid" + }, + "library_id": { + "type": "string", + "format": "uuid" + }, + "series_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "book_created" + ] + } + } + }, + { + "type": "object", + "description": "A book was updated", + "required": [ + "book_id", + "series_id", + "library_id", + "type" + ], + "properties": { + "book_id": { + "type": "string", + "format": "uuid" + }, + "fields": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "library_id": { + "type": "string", + "format": "uuid" + }, + "series_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "book_updated" + ] + } + } + }, + { + "type": "object", + "description": "A book was deleted", + "required": [ + "book_id", + "series_id", + "library_id", + "type" + ], + "properties": { + "book_id": { + "type": "string", + "format": "uuid" + }, + "library_id": { + "type": "string", + "format": "uuid" + }, + "series_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "book_deleted" + ] + } + } + }, + { + "type": "object", + "description": "A series was created", + "required": [ + "series_id", + "library_id", + "type" + ], + "properties": { + "library_id": { + "type": "string", + "format": "uuid" + }, + "series_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "series_created" + ] + } + } + }, + { + "type": "object", + "description": "A series was updated", + "required": [ + "series_id", + "library_id", + "type" + ], + "properties": { + "fields": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "library_id": { + "type": "string", + "format": "uuid" + }, + "series_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "series_updated" + ] + } + } + }, + { + "type": "object", + "description": "A series was deleted", + "required": [ + "series_id", + "library_id", + "type" + ], + "properties": { + "library_id": { + "type": "string", + "format": "uuid" + }, + "series_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "series_deleted" + ] + } + } + }, + { + "type": "object", + "description": "Series metadata was updated by a plugin", + "required": [ + "series_id", + "library_id", + "plugin_id", + "fields_updated", + "type" + ], + "properties": { + "fields_updated": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Fields that were updated" + }, + "library_id": { + "type": "string", + "format": "uuid" + }, + "plugin_id": { + "type": "string", + "format": "uuid", + "description": "Plugin that updated the metadata" + }, + "series_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "series_metadata_updated" + ] + } + } + }, + { + "type": "object", + "description": "Deleted books were purged from a series", + "required": [ + "series_id", + "library_id", + "count", + "type" + ], + "properties": { + "count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "library_id": { + "type": "string", + "format": "uuid" + }, + "series_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "series_bulk_purged" + ] + } + } + }, + { + "type": "object", + "description": "A cover image was updated", + "required": [ + "entity_type", + "entity_id", + "type" + ], + "properties": { + "entity_id": { + "type": "string", + "format": "uuid" + }, + "entity_type": { + "$ref": "#/components/schemas/EntityType" + }, + "library_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "cover_updated" + ] + } + } + }, + { + "type": "object", + "description": "A library was updated", + "required": [ + "library_id", + "type" + ], + "properties": { + "library_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "library_updated" + ] + } + } + }, + { + "type": "object", + "description": "A library was deleted", + "required": [ + "library_id", + "type" + ], + "properties": { + "library_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "library_deleted" + ] + } + } + } + ], + "description": "Specific event types for entity changes" + }, + "EntityType": { + "type": "string", + "description": "Type of entity that was changed", + "enum": [ + "book", + "series", + "library" + ] + }, + "EnvVarDto": { + "type": "object", + "description": "Environment variable key-value pair", + "required": [ + "key", + "value" + ], + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "ErrorGroupDto": { + "type": "object", + "description": "Summary of errors grouped by type", + "required": [ + "errorType", + "label", + "count", + "books" + ], + "properties": { + "books": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookWithErrorsDto" + }, + "description": "Books with this error type (paginated)" + }, + "count": { + "type": "integer", + "format": "int64", + "description": "Number of books with this error type", + "example": 5, + "minimum": 0 + }, + "errorType": { + "$ref": "#/components/schemas/BookErrorTypeDto", + "description": "Error type" + }, + "label": { + "type": "string", + "description": "Human-readable label for this error type", + "example": "Parser Error" + } + } + }, + "ErrorResponse": { + "type": "object", + "required": [ + "error", + "message" + ], + "properties": { + "details": {}, + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + "ExecutePluginRequest": { + "type": "object", + "description": "Request to execute a plugin action", + "required": [ + "action" + ], + "properties": { + "action": { + "$ref": "#/components/schemas/PluginActionRequest", + "description": "The action to execute, tagged by plugin type" + } + } + }, + "ExecutePluginResponse": { + "type": "object", + "description": "Response from executing a plugin method", + "required": [ + "success", + "latencyMs" + ], + "properties": { + "error": { + "type": [ + "string", + "null" + ], + "description": "Error message if failed" + }, + "latencyMs": { + "type": "integer", + "format": "int64", + "description": "Execution time in milliseconds", + "minimum": 0 + }, + "result": { + "description": "Result data (varies by method)" + }, + "success": { + "type": "boolean", + "description": "Whether the execution succeeded" + } + } + }, + "ExternalLinkDto": { + "type": "object", + "description": "External link data transfer object", + "required": [ + "id", + "seriesId", + "sourceName", + "url", + "createdAt", + "updatedAt" + ], + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the link was created", + "example": "2024-01-01T00:00:00Z" + }, + "externalId": { + "type": [ + "string", + "null" + ], + "description": "ID on the external source (if available)", + "example": "12345" + }, + "id": { + "type": "string", + "format": "uuid", + "description": "External link ID", + "example": "550e8400-e29b-41d4-a716-446655440060" + }, + "seriesId": { + "type": "string", + "format": "uuid", + "description": "Series ID", + "example": "550e8400-e29b-41d4-a716-446655440002" + }, + "sourceName": { + "type": "string", + "description": "Source name (e.g., \"myanimelist\", \"anilist\", \"mangadex\")", + "example": "myanimelist" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the link was last updated", + "example": "2024-01-15T10:30:00Z" + }, + "url": { + "type": "string", + "description": "URL to the external source", + "example": "https://myanimelist.net/manga/12345" + } + } + }, + "ExternalLinkListResponse": { + "type": "object", + "description": "Response containing a list of external links", + "required": [ + "links" + ], + "properties": { + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalLinkDto" + }, + "description": "List of external links" + } + } + }, + "ExternalRatingDto": { + "type": "object", + "description": "External rating data transfer object", + "required": [ + "id", + "seriesId", + "sourceName", + "rating", + "fetchedAt", + "createdAt", + "updatedAt" + ], + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the rating record was created", + "example": "2024-01-01T00:00:00Z" + }, + "fetchedAt": { + "type": "string", + "format": "date-time", + "description": "When the rating was last fetched from the source", + "example": "2024-01-15T10:30:00Z" + }, + "id": { + "type": "string", + "format": "uuid", + "description": "External rating ID", + "example": "550e8400-e29b-41d4-a716-446655440050" + }, + "rating": { + "type": "number", + "format": "double", + "description": "Rating value (0-100)", + "example": 85.5 + }, + "seriesId": { + "type": "string", + "format": "uuid", + "description": "Series ID", + "example": "550e8400-e29b-41d4-a716-446655440002" + }, + "sourceName": { + "type": "string", + "description": "Source name (e.g., \"myanimelist\", \"anilist\", \"mangabaka\")", + "example": "myanimelist" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the rating record was last updated", + "example": "2024-01-15T10:30:00Z" + }, + "voteCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Number of votes (if available)", + "example": 12500 + } + } + }, + "ExternalRatingListResponse": { + "type": "object", + "description": "Response containing a list of external ratings", + "required": [ + "ratings" + ], + "properties": { + "ratings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalRatingDto" + }, + "description": "List of external ratings" + } + } + }, + "FeedMetadata": { + "type": "object", + "description": "OPDS 2.0 Feed Metadata\n\nMetadata for navigation and publication feeds.", + "required": [ + "title" + ], + "properties": { + "currentPage": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Current page number (for pagination)" + }, + "itemsPerPage": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Items per page (for pagination)" + }, + "modified": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Last modification date" + }, + "numberOfItems": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Total number of items in the collection (for pagination)" + }, + "subtitle": { + "type": [ + "string", + "null" + ], + "description": "Optional subtitle" + }, + "title": { + "type": "string", + "description": "Title of the feed" + } + } + }, + "FieldApplyStatus": { + "type": "string", + "description": "Status of a field during metadata preview", + "enum": [ + "will_apply", + "locked", + "no_permission", + "unchanged", + "not_provided" + ] + }, + "FieldOperator": { + "oneOf": [ + { + "type": "object", + "description": "Exact match", + "required": [ + "value", + "operator" + ], + "properties": { + "operator": { + "type": "string", + "enum": [ + "is" + ] + }, + "value": { + "type": "string" + } + } + }, + { + "type": "object", + "description": "Not equal", + "required": [ + "value", + "operator" + ], + "properties": { + "operator": { + "type": "string", + "enum": [ + "isNot" + ] + }, + "value": { + "type": "string" + } + } + }, + { + "type": "object", + "description": "Field is null/empty", + "required": [ + "operator" + ], + "properties": { + "operator": { + "type": "string", + "enum": [ + "isNull" + ] + } + } + }, + { + "type": "object", + "description": "Field is not null/empty", + "required": [ + "operator" + ], + "properties": { + "operator": { + "type": "string", + "enum": [ + "isNotNull" + ] + } + } + }, + { + "type": "object", + "description": "String contains (case-insensitive)", + "required": [ + "value", + "operator" + ], + "properties": { + "operator": { + "type": "string", + "enum": [ + "contains" + ] + }, + "value": { + "type": "string" + } + } + }, + { + "type": "object", + "description": "String does not contain (case-insensitive)", + "required": [ + "value", + "operator" + ], + "properties": { + "operator": { + "type": "string", + "enum": [ + "doesNotContain" + ] + }, + "value": { + "type": "string" + } + } + }, + { + "type": "object", + "description": "String starts with (case-insensitive)", + "required": [ + "value", + "operator" + ], + "properties": { + "operator": { + "type": "string", + "enum": [ + "beginsWith" + ] + }, + "value": { + "type": "string" + } + } + }, + { + "type": "object", + "description": "String ends with (case-insensitive)", + "required": [ + "value", + "operator" + ], + "properties": { + "operator": { + "type": "string", + "enum": [ + "endsWith" + ] + }, + "value": { + "type": "string" + } + } + } + ], + "description": "Operators for string and equality comparisons" + }, + "FileSystemEntry": { + "type": "object", + "required": [ + "name", + "path", + "is_directory", + "is_readable" + ], + "properties": { + "is_directory": { + "type": "boolean", + "description": "Whether this is a directory" + }, + "is_readable": { + "type": "boolean", + "description": "Whether the entry is readable" + }, + "name": { + "type": "string", + "description": "Name of the file or directory" + }, + "path": { + "type": "string", + "description": "Full path to the entry" + } + }, + "example": { + "is_directory": true, + "is_readable": true, + "name": "Documents", + "path": "/home/user/Documents" + } + }, + "FlatStrategyConfig": { + "type": "object", + "description": "Configuration for flat scanning strategy", + "properties": { + "filenamePatterns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Regex patterns for extracting series name from filename\nPatterns are tried in order, first match wins" + }, + "requireMetadata": { + "type": "boolean", + "description": "If true, require metadata for series detection (no filename fallback)" + } + } + }, + "ForceRequest": { + "type": "object", + "properties": { + "force": { + "type": "boolean", + "description": "If true, regenerate thumbnails even if they exist. If false (default), only generate missing thumbnails.", + "example": false + } + } + }, + "FullBookResponse": { + "type": "object", + "description": "Full book response including book data and complete metadata with locks", + "required": [ + "id", + "libraryId", + "libraryName", + "seriesId", + "seriesName", + "title", + "filePath", + "fileFormat", + "fileSize", + "fileHash", + "pageCount", + "deleted", + "metadata", + "createdAt", + "updatedAt" + ], + "properties": { + "analysisError": { + "type": [ + "string", + "null" + ], + "description": "Error message if book analysis failed", + "example": "Failed to parse CBZ: invalid archive" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the book was added to the library", + "example": "2024-01-01T00:00:00Z" + }, + "deleted": { + "type": "boolean", + "description": "Whether the book has been soft-deleted", + "example": false + }, + "fileFormat": { + "type": "string", + "description": "File format (cbz, cbr, epub, pdf)", + "example": "cbz" + }, + "fileHash": { + "type": "string", + "description": "File hash for deduplication", + "example": "a1b2c3d4e5f6g7h8i9j0" + }, + "filePath": { + "type": "string", + "description": "Filesystem path to the book file", + "example": "/media/comics/Batman/Batman - Year One 001.cbz" + }, + "fileSize": { + "type": "integer", + "format": "int64", + "description": "File size in bytes", + "example": 52428800 + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Book unique identifier", + "example": "550e8400-e29b-41d4-a716-446655440001" + }, + "libraryId": { + "type": "string", + "format": "uuid", + "description": "Library this book belongs to", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "libraryName": { + "type": "string", + "description": "Name of the library", + "example": "Comics" + }, + "metadata": { + "$ref": "#/components/schemas/BookFullMetadata", + "description": "Complete book metadata with lock states" + }, + "number": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Book number within the series", + "example": 1 + }, + "pageCount": { + "type": "integer", + "format": "int32", + "description": "Number of pages in the book", + "example": 32 + }, + "readProgress": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ReadProgressResponse", + "description": "User's read progress for this book" + } + ] + }, + "readingDirection": { + "type": [ + "string", + "null" + ], + "description": "Effective reading direction (from series metadata, or library default)", + "example": "ltr" + }, + "seriesId": { + "type": "string", + "format": "uuid", + "description": "Series this book belongs to", + "example": "550e8400-e29b-41d4-a716-446655440002" + }, + "seriesName": { + "type": "string", + "description": "Name of the series", + "example": "Batman: Year One" + }, + "title": { + "type": "string", + "description": "Book title (display name)", + "example": "Batman: Year One #1" + }, + "titleSort": { + "type": [ + "string", + "null" + ], + "description": "Title used for sorting", + "example": "batman year one 001" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the book was last updated", + "example": "2024-01-15T10:30:00Z" + } + } + }, + "FullSeriesMetadataResponse": { + "type": "object", + "description": "Full series metadata response including all related data", + "required": [ + "seriesId", + "title", + "locks", + "genres", + "tags", + "alternateTitles", + "externalRatings", + "externalLinks", + "createdAt", + "updatedAt" + ], + "properties": { + "ageRating": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Age rating (e.g., 13, 16, 18)", + "example": 16 + }, + "alternateTitles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AlternateTitleDto" + }, + "description": "Alternate titles for this series" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Timestamps", + "example": "2024-01-01T00:00:00Z" + }, + "customMetadata": { + "type": [ + "object", + "null" + ], + "description": "Custom JSON metadata" + }, + "externalLinks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalLinkDto" + }, + "description": "External links to other sites" + }, + "externalRatings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalRatingDto" + }, + "description": "External ratings from various sources" + }, + "genres": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GenreDto" + }, + "description": "Genres assigned to this series" + }, + "imprint": { + "type": [ + "string", + "null" + ], + "description": "Imprint (sub-publisher)", + "example": "Vertigo" + }, + "language": { + "type": [ + "string", + "null" + ], + "description": "Language (BCP47 format: \"en\", \"ja\", \"ko\")", + "example": "en" + }, + "locks": { + "$ref": "#/components/schemas/MetadataLocks", + "description": "Lock states for all metadata fields" + }, + "publisher": { + "type": [ + "string", + "null" + ], + "description": "Publisher name", + "example": "DC Comics" + }, + "readingDirection": { + "type": [ + "string", + "null" + ], + "description": "Reading direction (ltr, rtl, ttb or webtoon)", + "example": "ltr" + }, + "seriesId": { + "type": "string", + "format": "uuid", + "description": "Series ID", + "example": "550e8400-e29b-41d4-a716-446655440002" + }, + "status": { + "type": [ + "string", + "null" + ], + "description": "Series status (ongoing, ended, hiatus, abandoned, unknown)", + "example": "ended" + }, + "summary": { + "type": [ + "string", + "null" + ], + "description": "Series description/summary", + "example": "The definitive origin story of Batman." + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagDto" + }, + "description": "Tags assigned to this series" + }, + "title": { + "type": "string", + "description": "Series title (usually same as series name)", + "example": "Batman: Year One" + }, + "titleSort": { + "type": [ + "string", + "null" + ], + "description": "Custom sort name for ordering", + "example": "Batman Year One" + }, + "totalBookCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Expected total book count (for ongoing series)", + "example": 4 + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "example": "2024-01-15T10:30:00Z" + }, + "year": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Release year", + "example": 1987 + } + } + }, + "FullSeriesResponse": { + "type": "object", + "description": "Full series response including series data and complete metadata", + "required": [ + "id", + "libraryId", + "libraryName", + "bookCount", + "metadata", + "genres", + "tags", + "alternateTitles", + "externalRatings", + "externalLinks", + "createdAt", + "updatedAt" + ], + "properties": { + "alternateTitles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AlternateTitleDto" + }, + "description": "Alternate titles for this series" + }, + "bookCount": { + "type": "integer", + "format": "int64", + "description": "Total number of books in this series", + "example": 4 + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the series was created", + "example": "2024-01-01T00:00:00Z" + }, + "externalLinks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalLinkDto" + }, + "description": "External links to other sites" + }, + "externalRatings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalRatingDto" + }, + "description": "External ratings from various sources" + }, + "genres": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GenreDto" + }, + "description": "Genres assigned to this series" + }, + "hasCustomCover": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the series has a custom cover uploaded", + "example": false + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Series unique identifier", + "example": "550e8400-e29b-41d4-a716-446655440002" + }, + "libraryId": { + "type": "string", + "format": "uuid", + "description": "Library unique identifier", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "libraryName": { + "type": "string", + "description": "Name of the library this series belongs to", + "example": "Comics" + }, + "metadata": { + "$ref": "#/components/schemas/SeriesFullMetadata", + "description": "Complete series metadata" + }, + "path": { + "type": [ + "string", + "null" + ], + "description": "Filesystem path to the series directory", + "example": "/media/comics/Batman - Year One" + }, + "selectedCoverSource": { + "type": [ + "string", + "null" + ], + "description": "Selected cover source (e.g., \"first_book\", \"custom\")", + "example": "first_book" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagDto" + }, + "description": "Tags assigned to this series" + }, + "unreadCount": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Number of unread books in this series (user-specific)", + "example": 2 + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the series was last updated", + "example": "2024-01-15T10:30:00Z" + } + } + }, + "GenerateBookThumbnailsRequest": { + "type": "object", + "description": "Request body for batch book thumbnail generation", + "properties": { + "force": { + "type": "boolean", + "description": "If true, regenerate all thumbnails even if they exist. If false (default), only generate missing thumbnails.", + "example": false + }, + "library_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Optional: scope to a specific library", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "series_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Optional: scope to a specific series (within library if both provided)", + "example": "550e8400-e29b-41d4-a716-446655440001" + } + } + }, + "GenerateSeriesThumbnailsRequest": { + "type": "object", + "description": "Request body for batch series thumbnail generation", + "properties": { + "force": { + "type": "boolean", + "description": "If true, regenerate all thumbnails even if they exist. If false (default), only generate missing thumbnails.", + "example": false + }, + "library_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Optional: scope to a specific library", + "example": "550e8400-e29b-41d4-a716-446655440000" + } + } + }, + "GenreDto": { + "type": "object", + "description": "Genre data transfer object", + "required": [ + "id", + "name", + "createdAt" + ], + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the genre was created", + "example": "2024-01-01T00:00:00Z" + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Genre ID", + "example": "550e8400-e29b-41d4-a716-446655440010" + }, + "name": { + "type": "string", + "description": "Genre name", + "example": "Action" + }, + "seriesCount": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Number of series with this genre", + "example": 42, + "minimum": 0 + } + } + }, + "GenreListResponse": { + "type": "object", + "description": "Response containing a list of genres", + "required": [ + "genres" + ], + "properties": { + "genres": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GenreDto" + }, + "description": "List of genres" + } + } + }, + "Group": { + "type": "object", + "description": "A group containing navigation or publications\n\nGroups allow organizing multiple collections within a single feed.", + "required": [ + "metadata" + ], + "properties": { + "metadata": { + "$ref": "#/components/schemas/FeedMetadata", + "description": "Group metadata (title required)" + }, + "navigation": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/Opds2Link" + }, + "description": "Navigation links within this group" + }, + "publications": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/Publication" + }, + "description": "Publications within this group" + } + } + }, + "ImageLink": { + "type": "object", + "description": "Image link with optional dimensions\n\nUsed for cover images and thumbnails in publications.", + "required": [ + "href", + "type" + ], + "properties": { + "height": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Height in pixels" + }, + "href": { + "type": "string", + "description": "URL to the image" + }, + "type": { + "type": "string", + "description": "Media type of the image" + }, + "width": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Width in pixels" + } + } + }, + "InitializeSetupRequest": { + "type": "object", + "description": "Initialize setup request - creates first admin user", + "required": [ + "username", + "email", + "password" + ], + "properties": { + "email": { + "type": "string", + "description": "Email address for the first admin user" + }, + "password": { + "type": "string", + "description": "Password for the first admin user" + }, + "username": { + "type": "string", + "description": "Username for the first admin user" + } + } + }, + "InitializeSetupResponse": { + "type": "object", + "description": "Initialize setup response - returns user and JWT token", + "required": [ + "user", + "accessToken", + "tokenType", + "expiresIn", + "message" + ], + "properties": { + "accessToken": { + "type": "string", + "description": "JWT access token" + }, + "expiresIn": { + "type": "integer", + "format": "int64", + "description": "Token expiry in seconds", + "minimum": 0 + }, + "message": { + "type": "string", + "description": "Success message" + }, + "tokenType": { + "type": "string", + "description": "Token type (always \"Bearer\")" + }, + "user": { + "$ref": "#/components/schemas/UserInfo", + "description": "Created user information" + } + } + }, + "KomgaAgeRestrictionDto": { + "type": "object", + "description": "Komga age restriction DTO", + "required": [ + "age", + "restriction" + ], + "properties": { + "age": { + "type": "integer", + "format": "int32", + "description": "Age limit" + }, + "restriction": { + "type": "string", + "description": "Restriction type (ALLOW_ONLY, EXCLUDE)" + } + } + }, + "KomgaAlternateTitleDto": { + "type": "object", + "description": "Komga alternate title DTO", + "required": [ + "label", + "title" + ], + "properties": { + "label": { + "type": "string", + "description": "Title label (e.g., \"Japanese\", \"Romaji\")" + }, + "title": { + "type": "string", + "description": "The alternate title text" + } + } + }, + "KomgaAuthorDto": { + "type": "object", + "description": "Komga author DTO", + "required": [ + "name", + "role" + ], + "properties": { + "name": { + "type": "string", + "description": "Author name" + }, + "role": { + "type": "string", + "description": "Author role (WRITER, PENCILLER, INKER, COLORIST, LETTERER, COVER, EDITOR)" + } + } + }, + "KomgaBookDto": { + "type": "object", + "description": "Komga book DTO\n\nBased on actual Komic traffic analysis. This is the main book representation.", + "required": [ + "id", + "seriesId", + "seriesTitle", + "libraryId", + "name", + "url", + "number", + "created", + "lastModified", + "fileLastModified", + "sizeBytes", + "size", + "media", + "metadata" + ], + "properties": { + "created": { + "type": "string", + "description": "Created timestamp (ISO 8601)" + }, + "deleted": { + "type": "boolean", + "description": "Whether book is deleted (soft delete)" + }, + "fileHash": { + "type": "string", + "description": "File hash" + }, + "fileLastModified": { + "type": "string", + "description": "File last modified timestamp (ISO 8601)" + }, + "id": { + "type": "string", + "description": "Book unique identifier (UUID as string)" + }, + "lastModified": { + "type": "string", + "description": "Last modified timestamp (ISO 8601)" + }, + "libraryId": { + "type": "string", + "description": "Library ID" + }, + "media": { + "$ref": "#/components/schemas/KomgaMediaDto", + "description": "Media information" + }, + "metadata": { + "$ref": "#/components/schemas/KomgaBookMetadataDto", + "description": "Book metadata" + }, + "name": { + "type": "string", + "description": "Book filename/name" + }, + "number": { + "type": "integer", + "format": "int32", + "description": "Book number in series" + }, + "oneshot": { + "type": "boolean", + "description": "Whether this is a oneshot" + }, + "readProgress": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/KomgaReadProgressDto", + "description": "User's read progress (null if not started)" + } + ] + }, + "seriesId": { + "type": "string", + "description": "Series ID" + }, + "seriesTitle": { + "type": "string", + "description": "Series title (required by Komic for display)" + }, + "size": { + "type": "string", + "description": "Human-readable file size (e.g., \"869.9 MiB\")" + }, + "sizeBytes": { + "type": "integer", + "format": "int64", + "description": "File size in bytes" + }, + "url": { + "type": "string", + "description": "File URL/path" + } + } + }, + "KomgaBookLinkDto": { + "type": "object", + "description": "Komga book link DTO", + "required": [ + "label", + "url" + ], + "properties": { + "label": { + "type": "string", + "description": "Link label" + }, + "url": { + "type": "string", + "description": "Link URL" + } + } + }, + "KomgaBookMetadataDto": { + "type": "object", + "description": "Komga book metadata DTO", + "required": [ + "title", + "number", + "numberSort", + "created", + "lastModified" + ], + "properties": { + "authors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KomgaAuthorDto" + }, + "description": "Authors list" + }, + "authorsLock": { + "type": "boolean", + "description": "Whether authors are locked" + }, + "created": { + "type": "string", + "description": "Created timestamp (ISO 8601)" + }, + "isbn": { + "type": "string", + "description": "ISBN" + }, + "isbnLock": { + "type": "boolean", + "description": "Whether ISBN is locked" + }, + "lastModified": { + "type": "string", + "description": "Last modified timestamp (ISO 8601)" + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KomgaBookLinkDto" + }, + "description": "Links" + }, + "linksLock": { + "type": "boolean", + "description": "Whether links are locked" + }, + "number": { + "type": "string", + "description": "Book number (display string)" + }, + "numberLock": { + "type": "boolean", + "description": "Whether number is locked" + }, + "numberSort": { + "type": "number", + "format": "double", + "description": "Number for sorting (float for chapter ordering)" + }, + "numberSortLock": { + "type": "boolean", + "description": "Whether number_sort is locked" + }, + "releaseDate": { + "type": [ + "string", + "null" + ], + "description": "Release date (YYYY-MM-DD or full ISO 8601)" + }, + "releaseDateLock": { + "type": "boolean", + "description": "Whether release_date is locked" + }, + "summary": { + "type": "string", + "description": "Book summary" + }, + "summaryLock": { + "type": "boolean", + "description": "Whether summary is locked" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags list" + }, + "tagsLock": { + "type": "boolean", + "description": "Whether tags are locked" + }, + "title": { + "type": "string", + "description": "Book title" + }, + "titleLock": { + "type": "boolean", + "description": "Whether title is locked" + } + } + }, + "KomgaBooksMetadataAggregationDto": { + "type": "object", + "description": "Komga books metadata aggregation DTO\n\nAggregated metadata from all books in the series.", + "required": [ + "created", + "lastModified" + ], + "properties": { + "authors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KomgaAuthorDto" + }, + "description": "Authors from all books" + }, + "created": { + "type": "string", + "description": "Created timestamp (ISO 8601)" + }, + "lastModified": { + "type": "string", + "description": "Last modified timestamp (ISO 8601)" + }, + "releaseDate": { + "type": [ + "string", + "null" + ], + "description": "Release date range (earliest)" + }, + "summary": { + "type": "string", + "description": "Summary (from first book or series)" + }, + "summaryNumber": { + "type": "string", + "description": "Summary number (if multiple summaries)" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags from all books" + } + } + }, + "KomgaBooksSearchRequestDto": { + "type": "object", + "description": "Request DTO for searching/filtering books (POST /api/v1/books/list)", + "properties": { + "author": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Authors filter" + }, + "condition": { + "description": "Condition object for complex queries (used by Komic for readStatus filtering)" + }, + "deleted": { + "type": [ + "boolean", + "null" + ], + "description": "Deleted filter" + }, + "fullTextSearch": { + "type": [ + "string", + "null" + ], + "description": "Full text search query" + }, + "libraryId": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Library IDs to filter by" + }, + "mediaStatus": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Media status filter" + }, + "readStatus": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Read status filter" + }, + "searchTerm": { + "type": [ + "string", + "null" + ], + "description": "Search term" + }, + "seriesId": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Series IDs to filter by" + }, + "tag": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Tags filter" + } + } + }, + "KomgaContentRestrictionsDto": { + "type": "object", + "description": "Komga content restrictions DTO", + "properties": { + "ageRestriction": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/KomgaAgeRestrictionDto", + "description": "Age restriction (null means no restriction)" + } + ] + }, + "labelsAllow": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels restriction" + }, + "labelsExclude": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels to exclude" + } + } + }, + "KomgaLibraryDto": { + "type": "object", + "description": "Komga library DTO\n\nBased on actual Komic traffic analysis - includes all fields observed in responses.", + "required": [ + "id", + "name", + "root" + ], + "properties": { + "analyzeDimensions": { + "type": "boolean", + "description": "Whether to analyze page dimensions" + }, + "convertToCbz": { + "type": "boolean", + "description": "Whether to convert archives to CBZ" + }, + "emptyTrashAfterScan": { + "type": "boolean", + "description": "Whether to empty trash after scan" + }, + "hashFiles": { + "type": "boolean", + "description": "Whether to hash files for deduplication" + }, + "hashKoreader": { + "type": "boolean", + "description": "Whether to hash files for KOReader sync" + }, + "hashPages": { + "type": "boolean", + "description": "Whether to hash pages" + }, + "id": { + "type": "string", + "description": "Library unique identifier (UUID as string)" + }, + "importBarcodeIsbn": { + "type": "boolean", + "description": "Whether to import barcode/ISBN" + }, + "importComicInfoBook": { + "type": "boolean", + "description": "Whether to import book info from ComicInfo.xml" + }, + "importComicInfoCollection": { + "type": "boolean", + "description": "Whether to import collection info from ComicInfo.xml" + }, + "importComicInfoReadList": { + "type": "boolean", + "description": "Whether to import read list from ComicInfo.xml" + }, + "importComicInfoSeries": { + "type": "boolean", + "description": "Whether to import series info from ComicInfo.xml" + }, + "importComicInfoSeriesAppendVolume": { + "type": "boolean", + "description": "Whether to append volume to series name from ComicInfo" + }, + "importEpubBook": { + "type": "boolean", + "description": "Whether to import EPUB book metadata" + }, + "importEpubSeries": { + "type": "boolean", + "description": "Whether to import EPUB series metadata" + }, + "importLocalArtwork": { + "type": "boolean", + "description": "Whether to import local artwork" + }, + "importMylarSeries": { + "type": "boolean", + "description": "Whether to import Mylar series data" + }, + "name": { + "type": "string", + "description": "Library display name" + }, + "oneshotsDirectory": { + "type": [ + "string", + "null" + ], + "description": "Directory path for oneshots (optional)" + }, + "repairExtensions": { + "type": "boolean", + "description": "Whether to repair file extensions" + }, + "root": { + "type": "string", + "description": "Root filesystem path" + }, + "scanCbx": { + "type": "boolean", + "description": "Whether to scan CBZ/CBR files" + }, + "scanDirectoryExclusions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Directory exclusion patterns" + }, + "scanEpub": { + "type": "boolean", + "description": "Whether to scan EPUB files" + }, + "scanForceModifiedTime": { + "type": "boolean", + "description": "Whether to force modified time for scan" + }, + "scanInterval": { + "type": "string", + "description": "Scan interval (WEEKLY, DAILY, HOURLY, EVERY_6H, EVERY_12H, DISABLED)" + }, + "scanOnStartup": { + "type": "boolean", + "description": "Whether to scan on startup" + }, + "scanPdf": { + "type": "boolean", + "description": "Whether to scan PDF files" + }, + "seriesCover": { + "type": "string", + "description": "Series cover selection strategy (FIRST, FIRST_UNREAD_OR_FIRST, FIRST_UNREAD_OR_LAST, LAST)" + }, + "unavailable": { + "type": "boolean", + "description": "Whether library is unavailable (path doesn't exist)" + } + } + }, + "KomgaMediaDto": { + "type": "object", + "description": "Komga media DTO\n\nInformation about the book's media/file.", + "required": [ + "status", + "mediaType", + "mediaProfile", + "pagesCount" + ], + "properties": { + "comment": { + "type": "string", + "description": "Comment/notes about media analysis" + }, + "epubDivinaCompatible": { + "type": "boolean", + "description": "Whether EPUB is DIVINA-compatible" + }, + "epubIsKepub": { + "type": "boolean", + "description": "Whether EPUB is a KePub file" + }, + "mediaProfile": { + "type": "string", + "description": "Media profile (DIVINA for comics/manga, PDF for PDFs)" + }, + "mediaType": { + "type": "string", + "description": "MIME type (e.g., \"application/zip\", \"application/epub+zip\", \"application/pdf\")" + }, + "pagesCount": { + "type": "integer", + "format": "int32", + "description": "Number of pages" + }, + "status": { + "type": "string", + "description": "Media status (READY, UNKNOWN, ERROR, UNSUPPORTED, OUTDATED)" + } + } + }, + "KomgaPageDto": { + "type": "object", + "description": "Komga page DTO\n\nRepresents a single page within a book.\nBased on actual Komic traffic analysis for GET /api/v1/books/{id}/pages", + "required": [ + "fileName", + "mediaType", + "number", + "width", + "height", + "sizeBytes", + "size" + ], + "properties": { + "fileName": { + "type": "string", + "description": "Original filename within archive" + }, + "height": { + "type": "integer", + "format": "int32", + "description": "Image height in pixels" + }, + "mediaType": { + "type": "string", + "description": "MIME type (e.g., \"image/png\", \"image/jpeg\", \"image/webp\")" + }, + "number": { + "type": "integer", + "format": "int32", + "description": "Page number (1-indexed)" + }, + "size": { + "type": "string", + "description": "Human-readable file size (e.g., \"2.5 MiB\")" + }, + "sizeBytes": { + "type": "integer", + "format": "int64", + "description": "Page file size in bytes" + }, + "width": { + "type": "integer", + "format": "int32", + "description": "Image width in pixels" + } + } + }, + "KomgaPage_KomgaBookDto": { + "type": "object", + "description": "Komga paginated response wrapper (Spring Data Page format)\n\nThis matches the exact structure Komic expects from Komga.", + "required": [ + "content", + "pageable", + "totalElements", + "totalPages", + "last", + "number", + "size", + "numberOfElements", + "first", + "empty", + "sort" + ], + "properties": { + "content": { + "type": "array", + "items": { + "type": "object", + "description": "Komga book DTO\n\nBased on actual Komic traffic analysis. This is the main book representation.", + "required": [ + "id", + "seriesId", + "seriesTitle", + "libraryId", + "name", + "url", + "number", + "created", + "lastModified", + "fileLastModified", + "sizeBytes", + "size", + "media", + "metadata" + ], + "properties": { + "created": { + "type": "string", + "description": "Created timestamp (ISO 8601)" + }, + "deleted": { + "type": "boolean", + "description": "Whether book is deleted (soft delete)" + }, + "fileHash": { + "type": "string", + "description": "File hash" + }, + "fileLastModified": { + "type": "string", + "description": "File last modified timestamp (ISO 8601)" + }, + "id": { + "type": "string", + "description": "Book unique identifier (UUID as string)" + }, + "lastModified": { + "type": "string", + "description": "Last modified timestamp (ISO 8601)" + }, + "libraryId": { + "type": "string", + "description": "Library ID" + }, + "media": { + "$ref": "#/components/schemas/KomgaMediaDto", + "description": "Media information" + }, + "metadata": { + "$ref": "#/components/schemas/KomgaBookMetadataDto", + "description": "Book metadata" + }, + "name": { + "type": "string", + "description": "Book filename/name" + }, + "number": { + "type": "integer", + "format": "int32", + "description": "Book number in series" + }, + "oneshot": { + "type": "boolean", + "description": "Whether this is a oneshot" + }, + "readProgress": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/KomgaReadProgressDto", + "description": "User's read progress (null if not started)" + } + ] + }, + "seriesId": { + "type": "string", + "description": "Series ID" + }, + "seriesTitle": { + "type": "string", + "description": "Series title (required by Komic for display)" + }, + "size": { + "type": "string", + "description": "Human-readable file size (e.g., \"869.9 MiB\")" + }, + "sizeBytes": { + "type": "integer", + "format": "int64", + "description": "File size in bytes" + }, + "url": { + "type": "string", + "description": "File URL/path" + } + } + }, + "description": "The content items for this page" + }, + "empty": { + "type": "boolean", + "description": "Whether the page is empty" + }, + "first": { + "type": "boolean", + "description": "Whether this is the first page" + }, + "last": { + "type": "boolean", + "description": "Whether this is the last page" + }, + "number": { + "type": "integer", + "format": "int32", + "description": "Current page number (0-indexed)" + }, + "numberOfElements": { + "type": "integer", + "format": "int32", + "description": "Number of elements on this page" + }, + "pageable": { + "$ref": "#/components/schemas/KomgaPageable", + "description": "Pageable information" + }, + "size": { + "type": "integer", + "format": "int32", + "description": "Page size" + }, + "sort": { + "$ref": "#/components/schemas/KomgaSort", + "description": "Sort information" + }, + "totalElements": { + "type": "integer", + "format": "int64", + "description": "Total number of elements across all pages" + }, + "totalPages": { + "type": "integer", + "format": "int32", + "description": "Total number of pages" + } + } + }, + "KomgaPage_KomgaSeriesDto": { + "type": "object", + "description": "Komga paginated response wrapper (Spring Data Page format)\n\nThis matches the exact structure Komic expects from Komga.", + "required": [ + "content", + "pageable", + "totalElements", + "totalPages", + "last", + "number", + "size", + "numberOfElements", + "first", + "empty", + "sort" + ], + "properties": { + "content": { + "type": "array", + "items": { + "type": "object", + "description": "Komga series DTO\n\nBased on actual Komic traffic analysis.", + "required": [ + "id", + "libraryId", + "name", + "url", + "created", + "lastModified", + "fileLastModified", + "booksCount", + "booksReadCount", + "booksUnreadCount", + "booksInProgressCount", + "metadata", + "booksMetadata" + ], + "properties": { + "booksCount": { + "type": "integer", + "format": "int32", + "description": "Total books count" + }, + "booksInProgressCount": { + "type": "integer", + "format": "int32", + "description": "In-progress books count" + }, + "booksMetadata": { + "$ref": "#/components/schemas/KomgaBooksMetadataAggregationDto", + "description": "Aggregated books metadata" + }, + "booksReadCount": { + "type": "integer", + "format": "int32", + "description": "Read books count" + }, + "booksUnreadCount": { + "type": "integer", + "format": "int32", + "description": "Unread books count" + }, + "created": { + "type": "string", + "description": "Created timestamp (ISO 8601)" + }, + "deleted": { + "type": "boolean", + "description": "Whether series is deleted (soft delete)" + }, + "fileLastModified": { + "type": "string", + "description": "File last modified timestamp (ISO 8601)" + }, + "id": { + "type": "string", + "description": "Series unique identifier (UUID as string)" + }, + "lastModified": { + "type": "string", + "description": "Last modified timestamp (ISO 8601)" + }, + "libraryId": { + "type": "string", + "description": "Library ID" + }, + "metadata": { + "$ref": "#/components/schemas/KomgaSeriesMetadataDto", + "description": "Series metadata" + }, + "name": { + "type": "string", + "description": "Series name" + }, + "oneshot": { + "type": "boolean", + "description": "Whether this is a oneshot (single book)" + }, + "url": { + "type": "string", + "description": "File URL/path" + } + } + }, + "description": "The content items for this page" + }, + "empty": { + "type": "boolean", + "description": "Whether the page is empty" + }, + "first": { + "type": "boolean", + "description": "Whether this is the first page" + }, + "last": { + "type": "boolean", + "description": "Whether this is the last page" + }, + "number": { + "type": "integer", + "format": "int32", + "description": "Current page number (0-indexed)" + }, + "numberOfElements": { + "type": "integer", + "format": "int32", + "description": "Number of elements on this page" + }, + "pageable": { + "$ref": "#/components/schemas/KomgaPageable", + "description": "Pageable information" + }, + "size": { + "type": "integer", + "format": "int32", + "description": "Page size" + }, + "sort": { + "$ref": "#/components/schemas/KomgaSort", + "description": "Sort information" + }, + "totalElements": { + "type": "integer", + "format": "int64", + "description": "Total number of elements across all pages" + }, + "totalPages": { + "type": "integer", + "format": "int32", + "description": "Total number of pages" + } + } + }, + "KomgaPageable": { + "type": "object", + "description": "Komga pageable information (Spring Data style)", + "required": [ + "pageNumber", + "pageSize", + "sort", + "offset", + "paged", + "unpaged" + ], + "properties": { + "offset": { + "type": "integer", + "format": "int64", + "description": "Offset from start (page_number * page_size)" + }, + "pageNumber": { + "type": "integer", + "format": "int32", + "description": "Current page number (0-indexed)" + }, + "pageSize": { + "type": "integer", + "format": "int32", + "description": "Page size (number of items per page)" + }, + "paged": { + "type": "boolean", + "description": "Whether the pageable is paged (always true for paginated results)" + }, + "sort": { + "$ref": "#/components/schemas/KomgaSort", + "description": "Sort information" + }, + "unpaged": { + "type": "boolean", + "description": "Whether the pageable is unpaged (always false for paginated results)" + } + } + }, + "KomgaReadProgressDto": { + "type": "object", + "description": "Komga read progress DTO", + "required": [ + "page", + "completed", + "created", + "lastModified" + ], + "properties": { + "completed": { + "type": "boolean", + "description": "Whether the book is completed" + }, + "created": { + "type": "string", + "description": "Created timestamp (ISO 8601)" + }, + "deviceId": { + "type": "string", + "description": "Device ID that last updated progress" + }, + "deviceName": { + "type": "string", + "description": "Device name that last updated progress" + }, + "lastModified": { + "type": "string", + "description": "Last modified timestamp (ISO 8601)" + }, + "page": { + "type": "integer", + "format": "int32", + "description": "Current page number (1-indexed)" + }, + "readDate": { + "type": [ + "string", + "null" + ], + "description": "When the book was last read (ISO 8601)" + } + } + }, + "KomgaReadProgressUpdateDto": { + "type": "object", + "description": "Request DTO for updating read progress\n\nObserved from actual Komic traffic: `{ \"completed\": false, \"page\": 151 }`", + "properties": { + "completed": { + "type": [ + "boolean", + "null" + ], + "description": "Whether book is completed" + }, + "deviceId": { + "type": [ + "string", + "null" + ], + "description": "Device ID (optional, may be used by some clients)" + }, + "deviceName": { + "type": [ + "string", + "null" + ], + "description": "Device name (optional, may be used by some clients)" + }, + "page": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Current page number (1-indexed)" + } + } + }, + "KomgaSeriesDto": { + "type": "object", + "description": "Komga series DTO\n\nBased on actual Komic traffic analysis.", + "required": [ + "id", + "libraryId", + "name", + "url", + "created", + "lastModified", + "fileLastModified", + "booksCount", + "booksReadCount", + "booksUnreadCount", + "booksInProgressCount", + "metadata", + "booksMetadata" + ], + "properties": { + "booksCount": { + "type": "integer", + "format": "int32", + "description": "Total books count" + }, + "booksInProgressCount": { + "type": "integer", + "format": "int32", + "description": "In-progress books count" + }, + "booksMetadata": { + "$ref": "#/components/schemas/KomgaBooksMetadataAggregationDto", + "description": "Aggregated books metadata" + }, + "booksReadCount": { + "type": "integer", + "format": "int32", + "description": "Read books count" + }, + "booksUnreadCount": { + "type": "integer", + "format": "int32", + "description": "Unread books count" + }, + "created": { + "type": "string", + "description": "Created timestamp (ISO 8601)" + }, + "deleted": { + "type": "boolean", + "description": "Whether series is deleted (soft delete)" + }, + "fileLastModified": { + "type": "string", + "description": "File last modified timestamp (ISO 8601)" + }, + "id": { + "type": "string", + "description": "Series unique identifier (UUID as string)" + }, + "lastModified": { + "type": "string", + "description": "Last modified timestamp (ISO 8601)" + }, + "libraryId": { + "type": "string", + "description": "Library ID" + }, + "metadata": { + "$ref": "#/components/schemas/KomgaSeriesMetadataDto", + "description": "Series metadata" + }, + "name": { + "type": "string", + "description": "Series name" + }, + "oneshot": { + "type": "boolean", + "description": "Whether this is a oneshot (single book)" + }, + "url": { + "type": "string", + "description": "File URL/path" + } + } + }, + "KomgaSeriesMetadataDto": { + "type": "object", + "description": "Komga series metadata DTO", + "required": [ + "status", + "title", + "titleSort", + "created", + "lastModified" + ], + "properties": { + "ageRating": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Age rating" + }, + "ageRatingLock": { + "type": "boolean", + "description": "Whether age_rating is locked" + }, + "alternateTitles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KomgaAlternateTitleDto" + }, + "description": "Alternate titles" + }, + "alternateTitlesLock": { + "type": "boolean", + "description": "Whether alternate_titles are locked" + }, + "created": { + "type": "string", + "description": "Metadata created timestamp (ISO 8601)" + }, + "genres": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Genres list" + }, + "genresLock": { + "type": "boolean", + "description": "Whether genres are locked" + }, + "language": { + "type": "string", + "description": "Language code" + }, + "languageLock": { + "type": "boolean", + "description": "Whether language is locked" + }, + "lastModified": { + "type": "string", + "description": "Metadata last modified timestamp (ISO 8601)" + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KomgaWebLinkDto" + }, + "description": "External links" + }, + "linksLock": { + "type": "boolean", + "description": "Whether links are locked" + }, + "publisher": { + "type": "string", + "description": "Publisher name" + }, + "publisherLock": { + "type": "boolean", + "description": "Whether publisher is locked" + }, + "readingDirection": { + "type": [ + "string", + "null" + ], + "description": "Reading direction (LEFT_TO_RIGHT, RIGHT_TO_LEFT, VERTICAL, WEBTOON)" + }, + "readingDirectionLock": { + "type": "boolean", + "description": "Whether reading_direction is locked" + }, + "sharingLabels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Sharing labels" + }, + "sharingLabelsLock": { + "type": "boolean", + "description": "Whether sharing_labels are locked" + }, + "status": { + "type": "string", + "description": "Series status (ENDED, ONGOING, ABANDONED, HIATUS)" + }, + "statusLock": { + "type": "boolean", + "description": "Whether status is locked" + }, + "summary": { + "type": "string", + "description": "Series summary/description" + }, + "summaryLock": { + "type": "boolean", + "description": "Whether summary is locked" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags list" + }, + "tagsLock": { + "type": "boolean", + "description": "Whether tags are locked" + }, + "title": { + "type": "string", + "description": "Series title" + }, + "titleLock": { + "type": "boolean", + "description": "Whether title is locked" + }, + "titleSort": { + "type": "string", + "description": "Sort title" + }, + "titleSortLock": { + "type": "boolean", + "description": "Whether title_sort is locked" + }, + "totalBookCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Total book count (expected)" + }, + "totalBookCountLock": { + "type": "boolean", + "description": "Whether total_book_count is locked" + } + } + }, + "KomgaSort": { + "type": "object", + "description": "Komga pagination sort information", + "required": [ + "sorted", + "unsorted", + "empty" + ], + "properties": { + "empty": { + "type": "boolean", + "description": "Whether the sort is empty" + }, + "sorted": { + "type": "boolean", + "description": "Whether the results are sorted in ascending or descending order" + }, + "unsorted": { + "type": "boolean", + "description": "Whether the results are unsorted" + } + } + }, + "KomgaUserDto": { + "type": "object", + "description": "Komga user DTO\n\nResponse for GET /api/v1/users/me", + "required": [ + "id", + "email", + "roles" + ], + "properties": { + "contentRestrictions": { + "$ref": "#/components/schemas/KomgaContentRestrictionsDto", + "description": "User's content restrictions" + }, + "email": { + "type": "string", + "description": "User email address" + }, + "id": { + "type": "string", + "description": "User unique identifier (UUID as string)" + }, + "labelsAllow": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Whether user can share content" + }, + "labelsExclude": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels to exclude from sharing" + }, + "roles": { + "type": "array", + "items": { + "type": "string" + }, + "description": "User roles (e.g., [\"ADMIN\"], [\"USER\"])" + }, + "sharedAllLibraries": { + "type": "boolean", + "description": "Whether all libraries are shared with this user" + }, + "sharedLibrariesIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Shared libraries access - list of library IDs user can access\nEmpty means access to all libraries" } } }, - "KomgaSeriesDto": { + "KomgaWebLinkDto": { "type": "object", - "description": "Komga series DTO\n\nBased on actual Komic traffic analysis.", + "description": "Komga web link DTO", + "required": [ + "label", + "url" + ], + "properties": { + "label": { + "type": "string", + "description": "Link label" + }, + "url": { + "type": "string", + "description": "Link URL" + } + } + }, + "LibraryDto": { + "type": "object", + "description": "Library data transfer object", "required": [ "id", - "libraryId", "name", - "url", - "created", - "lastModified", - "fileLastModified", - "booksCount", - "booksReadCount", - "booksUnreadCount", - "booksInProgressCount", - "metadata", - "booksMetadata" + "path", + "isActive", + "seriesStrategy", + "bookStrategy", + "numberStrategy", + "createdAt", + "updatedAt", + "defaultReadingDirection" ], "properties": { - "booksCount": { - "type": "integer", - "format": "int32", - "description": "Total books count" + "allowedFormats": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Allowed file formats (e.g., [\"CBZ\", \"CBR\", \"EPUB\"])", + "example": [ + "CBZ", + "CBR", + "PDF" + ] }, - "booksInProgressCount": { - "type": "integer", - "format": "int32", - "description": "In-progress books count" + "bookConfig": { + "description": "Book strategy-specific configuration (JSON)" }, - "booksMetadata": { - "$ref": "#/components/schemas/KomgaBooksMetadataAggregationDto", - "description": "Aggregated books metadata" + "bookCount": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Total number of books in this library", + "example": 1250 }, - "booksReadCount": { - "type": "integer", - "format": "int32", - "description": "Read books count" + "bookStrategy": { + "$ref": "#/components/schemas/BookStrategy", + "description": "Book naming strategy (filename, metadata_first, smart, series_name)" }, - "booksUnreadCount": { - "type": "integer", - "format": "int32", - "description": "Unread books count" + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the library was created", + "example": "2024-01-01T00:00:00Z" }, - "created": { + "defaultReadingDirection": { "type": "string", - "description": "Created timestamp (ISO 8601)" + "description": "Default reading direction for books in this library (ltr, rtl, ttb or webtoon)", + "example": "ltr" }, - "deleted": { - "type": "boolean", - "description": "Whether series is deleted (soft delete)" + "description": { + "type": [ + "string", + "null" + ], + "description": "Optional description", + "example": "My comic book collection" }, - "fileLastModified": { - "type": "string", - "description": "File last modified timestamp (ISO 8601)" + "excludedPatterns": { + "type": [ + "string", + "null" + ], + "description": "Excluded path patterns (newline-separated, e.g., \".DS_Store\\nThumbs.db\")", + "example": ".DS_Store\nThumbs.db" }, "id": { "type": "string", - "description": "Series unique identifier (UUID as string)" + "format": "uuid", + "description": "Library unique identifier", + "example": "550e8400-e29b-41d4-a716-446655440000" }, - "lastModified": { + "isActive": { + "type": "boolean", + "description": "Whether the library is active", + "example": true + }, + "lastScannedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the library was last scanned", + "example": "2024-01-15T10:30:00Z" + }, + "name": { "type": "string", - "description": "Last modified timestamp (ISO 8601)" + "description": "Library name", + "example": "Comics" }, - "libraryId": { + "numberConfig": { + "description": "Number strategy-specific configuration (JSON)" + }, + "numberStrategy": { + "$ref": "#/components/schemas/NumberStrategy", + "description": "Book number strategy (file_order, metadata, filename, smart)" + }, + "path": { "type": "string", - "description": "Library ID" + "description": "Filesystem path to the library root", + "example": "/media/comics" }, - "metadata": { - "$ref": "#/components/schemas/KomgaSeriesMetadataDto", - "description": "Series metadata" + "scanningConfig": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ScanningConfigDto", + "description": "Scanning configuration for scheduled scans" + } + ] }, - "name": { + "seriesConfig": { + "description": "Strategy-specific configuration (JSON)" + }, + "seriesCount": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Total number of series in this library", + "example": 85 + }, + "seriesStrategy": { + "$ref": "#/components/schemas/SeriesStrategy", + "description": "Series detection strategy (series_volume, series_volume_chapter, flat, etc.)" + }, + "updatedAt": { "type": "string", - "description": "Series name" + "format": "date-time", + "description": "When the library was last updated", + "example": "2024-01-15T10:30:00Z" + } + } + }, + "LibraryMetricsDto": { + "type": "object", + "description": "Metrics for a single library", + "required": [ + "id", + "name", + "series_count", + "book_count", + "total_size" + ], + "properties": { + "book_count": { + "type": "integer", + "format": "int64", + "description": "Number of books in this library", + "example": 1200 }, - "oneshot": { - "type": "boolean", - "description": "Whether this is a oneshot (single book)" + "id": { + "type": "string", + "format": "uuid", + "description": "Library ID", + "example": "550e8400-e29b-41d4-a716-446655440000" }, - "url": { + "name": { "type": "string", - "description": "File URL/path" + "description": "Library name", + "example": "Comics" + }, + "series_count": { + "type": "integer", + "format": "int64", + "description": "Number of series in this library", + "example": 45 + }, + "total_size": { + "type": "integer", + "format": "int64", + "description": "Total size of books in bytes (approx. 15GB)", + "example": "15728640000" } } }, - "KomgaSeriesMetadataDto": { + "LinkProperties": { "type": "object", - "description": "Komga series metadata DTO", - "required": [ - "status", - "title", - "titleSort", - "created", - "lastModified" - ], + "description": "Additional properties that can be attached to links", "properties": { - "ageRating": { + "numberOfItems": { "type": [ "integer", "null" ], - "format": "int32", - "description": "Age rating" - }, - "ageRatingLock": { - "type": "boolean", - "description": "Whether age_rating is locked" - }, - "alternateTitles": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KomgaAlternateTitleDto" - }, - "description": "Alternate titles" - }, - "alternateTitlesLock": { - "type": "boolean", - "description": "Whether alternate_titles are locked" - }, - "created": { - "type": "string", - "description": "Metadata created timestamp (ISO 8601)" - }, - "genres": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Genres list" - }, - "genresLock": { - "type": "boolean", - "description": "Whether genres are locked" - }, - "language": { - "type": "string", - "description": "Language code" - }, - "languageLock": { - "type": "boolean", - "description": "Whether language is locked" - }, - "lastModified": { - "type": "string", - "description": "Metadata last modified timestamp (ISO 8601)" - }, - "links": { + "format": "int64", + "description": "Number of items in the linked collection" + } + } + }, + "ListDuplicatesResponse": { + "type": "object", + "description": "Response for listing duplicates", + "required": [ + "duplicates", + "total_groups", + "total_duplicate_books" + ], + "properties": { + "duplicates": { "type": "array", "items": { - "$ref": "#/components/schemas/KomgaWebLinkDto" + "$ref": "#/components/schemas/DuplicateGroup" }, - "description": "External links" - }, - "linksLock": { - "type": "boolean", - "description": "Whether links are locked" - }, - "publisher": { - "type": "string", - "description": "Publisher name" + "description": "List of duplicate groups" }, - "publisherLock": { - "type": "boolean", - "description": "Whether publisher is locked" + "total_duplicate_books": { + "type": "integer", + "description": "Total number of books that are duplicates", + "example": 15, + "minimum": 0 }, - "readingDirection": { + "total_groups": { + "type": "integer", + "description": "Total number of duplicate groups", + "example": 5, + "minimum": 0 + } + } + }, + "ListSettingsQuery": { + "type": "object", + "description": "Query parameters for listing settings", + "properties": { + "category": { "type": [ "string", "null" ], - "description": "Reading direction (LEFT_TO_RIGHT, RIGHT_TO_LEFT, VERTICAL, WEBTOON)" - }, - "readingDirectionLock": { - "type": "boolean", - "description": "Whether reading_direction is locked" - }, - "sharingLabels": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Sharing labels" - }, - "sharingLabelsLock": { - "type": "boolean", - "description": "Whether sharing_labels are locked" - }, - "status": { + "description": "Filter settings by category", + "example": "scanning" + } + } + }, + "LoginRequest": { + "type": "object", + "description": "Login request", + "required": [ + "username", + "password" + ], + "properties": { + "password": { "type": "string", - "description": "Series status (ENDED, ONGOING, ABANDONED, HIATUS)" - }, - "statusLock": { - "type": "boolean", - "description": "Whether status is locked" + "description": "Password", + "example": "password123" }, - "summary": { + "username": { "type": "string", - "description": "Series summary/description" - }, - "summaryLock": { - "type": "boolean", - "description": "Whether summary is locked" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Tags list" - }, - "tagsLock": { - "type": "boolean", - "description": "Whether tags are locked" - }, - "title": { + "description": "Username or email", + "example": "admin" + } + } + }, + "LoginResponse": { + "type": "object", + "description": "Login response with JWT token", + "required": [ + "accessToken", + "tokenType", + "expiresIn", + "user" + ], + "properties": { + "accessToken": { "type": "string", - "description": "Series title" + "description": "JWT access token", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ" }, - "titleLock": { - "type": "boolean", - "description": "Whether title is locked" + "expiresIn": { + "type": "integer", + "format": "int64", + "description": "Token expiry in seconds", + "example": 86400, + "minimum": 0 }, - "titleSort": { + "tokenType": { "type": "string", - "description": "Sort title" - }, - "titleSortLock": { - "type": "boolean", - "description": "Whether title_sort is locked" + "description": "Token type (always \"Bearer\")", + "example": "Bearer" }, - "totalBookCount": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Total book count (expected)" + "user": { + "$ref": "#/components/schemas/UserInfo", + "description": "User information" + } + } + }, + "MarkReadResponse": { + "type": "object", + "description": "Response for bulk mark as read/unread operations", + "required": [ + "count", + "message" + ], + "properties": { + "count": { + "type": "integer", + "description": "Number of books affected", + "example": 5, + "minimum": 0 }, - "totalBookCountLock": { - "type": "boolean", - "description": "Whether total_book_count is locked" + "message": { + "type": "string", + "description": "Message describing the operation", + "example": "Marked 5 books as read" + } + } + }, + "MessageResponse": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string", + "description": "Response message", + "example": "Task 550e8400-e29b-41d4-a716-446655440000 cancelled" } } }, - "KomgaSort": { + "MetadataAction": { + "type": "string", + "description": "Action for metadata plugins", + "enum": [ + "search", + "get", + "match" + ] + }, + "MetadataApplyRequest": { "type": "object", - "description": "Komga pagination sort information", + "description": "Request to apply metadata from a plugin", "required": [ - "sorted", - "unsorted", - "empty" + "pluginId", + "externalId" ], "properties": { - "empty": { - "type": "boolean", - "description": "Whether the sort is empty" + "externalId": { + "type": "string", + "description": "External ID from the plugin's search results" }, - "sorted": { - "type": "boolean", - "description": "Whether the results are sorted in ascending or descending order" + "fields": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Optional list of fields to apply (default: all applicable fields)" }, - "unsorted": { - "type": "boolean", - "description": "Whether the results are unsorted" + "pluginId": { + "type": "string", + "format": "uuid", + "description": "Plugin ID to fetch metadata from" } } }, - "KomgaUserDto": { + "MetadataApplyResponse": { "type": "object", - "description": "Komga user DTO\n\nResponse for GET /api/v1/users/me", + "description": "Response after applying metadata", "required": [ - "id", - "email", - "roles" + "success", + "appliedFields", + "skippedFields", + "message" ], "properties": { - "contentRestrictions": { - "$ref": "#/components/schemas/KomgaContentRestrictionsDto", - "description": "User's content restrictions" - }, - "email": { - "type": "string", - "description": "User email address" - }, - "id": { - "type": "string", - "description": "User unique identifier (UUID as string)" - }, - "labelsAllow": { + "appliedFields": { "type": "array", "items": { "type": "string" }, - "description": "Whether user can share content" + "description": "Fields that were applied" }, - "labelsExclude": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Labels to exclude from sharing" + "message": { + "type": "string", + "description": "Message" }, - "roles": { + "skippedFields": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/SkippedField" }, - "description": "User roles (e.g., [\"ADMIN\"], [\"USER\"])" + "description": "Fields that were skipped (with reasons)" }, - "sharedAllLibraries": { + "success": { "type": "boolean", - "description": "Whether all libraries are shared with this user" - }, - "sharedLibrariesIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Shared libraries access - list of library IDs user can access\nEmpty means access to all libraries" + "description": "Whether the operation succeeded" } } }, - "KomgaWebLinkDto": { + "MetadataAutoMatchRequest": { "type": "object", - "description": "Komga web link DTO", + "description": "Request to auto-match and apply metadata from a plugin", "required": [ - "label", - "url" + "pluginId" ], "properties": { - "label": { + "pluginId": { "type": "string", - "description": "Link label" + "format": "uuid", + "description": "Plugin ID to use for matching" }, - "url": { - "type": "string", - "description": "Link URL" + "query": { + "type": [ + "string", + "null" + ], + "description": "Optional query to use for matching (defaults to series title)" } } }, - "LibraryDto": { + "MetadataAutoMatchResponse": { "type": "object", - "description": "Library data transfer object", + "description": "Response after auto-matching metadata", "required": [ - "id", - "name", - "path", - "isActive", - "seriesStrategy", - "bookStrategy", - "numberStrategy", - "createdAt", - "updatedAt", - "defaultReadingDirection" + "success", + "appliedFields", + "skippedFields", + "message" ], "properties": { - "allowedFormats": { + "appliedFields": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Fields that were applied" + }, + "externalUrl": { "type": [ - "array", + "string", "null" ], + "description": "External URL (link to matched item on provider)" + }, + "matchedResult": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PluginSearchResultDto", + "description": "The search result that was matched" + } + ] + }, + "message": { + "type": "string", + "description": "Message" + }, + "skippedFields": { + "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/SkippedField" }, - "description": "Allowed file formats (e.g., [\"CBZ\", \"CBR\", \"EPUB\"])", - "example": [ - "CBZ", - "CBR", - "PDF" - ] + "description": "Fields that were skipped (with reasons)" }, - "bookConfig": { - "description": "Book strategy-specific configuration (JSON)" + "success": { + "type": "boolean", + "description": "Whether the operation succeeded" + } + } + }, + "MetadataContentType": { + "type": "string", + "description": "Content types that a metadata provider can support", + "enum": [ + "series" + ] + }, + "MetadataFieldPreview": { + "type": "object", + "description": "A single field in the metadata preview", + "required": [ + "field", + "status" + ], + "properties": { + "currentValue": { + "description": "Current value in database" }, - "bookCount": { + "field": { + "type": "string", + "description": "Field name" + }, + "proposedValue": { + "description": "Proposed value from plugin" + }, + "reason": { "type": [ - "integer", + "string", "null" ], - "format": "int64", - "description": "Total number of books in this library", - "example": 1250 + "description": "Human-readable reason for status" }, - "bookStrategy": { - "$ref": "#/components/schemas/BookStrategy", - "description": "Book naming strategy (filename, metadata_first, smart, series_name)" + "status": { + "$ref": "#/components/schemas/FieldApplyStatus", + "description": "Apply status" + } + } + }, + "MetadataLocks": { + "type": "object", + "description": "Lock states for all lockable metadata fields", + "required": [ + "title", + "titleSort", + "summary", + "publisher", + "imprint", + "status", + "ageRating", + "language", + "readingDirection", + "year", + "totalBookCount", + "genres", + "tags", + "customMetadata" + ], + "properties": { + "ageRating": { + "type": "boolean", + "description": "Whether the age_rating field is locked", + "example": false }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the library was created", - "example": "2024-01-01T00:00:00Z" + "customMetadata": { + "type": "boolean", + "description": "Whether the custom_metadata field is locked", + "example": false }, - "defaultReadingDirection": { + "genres": { + "type": "boolean", + "description": "Whether the genres are locked", + "example": false + }, + "imprint": { + "type": "boolean", + "description": "Whether the imprint field is locked", + "example": false + }, + "language": { + "type": "boolean", + "description": "Whether the language field is locked", + "example": false + }, + "publisher": { + "type": "boolean", + "description": "Whether the publisher field is locked", + "example": false + }, + "readingDirection": { + "type": "boolean", + "description": "Whether the reading_direction field is locked", + "example": false + }, + "status": { + "type": "boolean", + "description": "Whether the status field is locked", + "example": false + }, + "summary": { + "type": "boolean", + "description": "Whether the summary field is locked", + "example": true + }, + "tags": { + "type": "boolean", + "description": "Whether the tags are locked", + "example": false + }, + "title": { + "type": "boolean", + "description": "Whether the title field is locked", + "example": false + }, + "titleSort": { + "type": "boolean", + "description": "Whether the title_sort field is locked", + "example": false + }, + "totalBookCount": { + "type": "boolean", + "description": "Whether the total_book_count field is locked", + "example": false + }, + "year": { + "type": "boolean", + "description": "Whether the year field is locked", + "example": false + } + } + }, + "MetadataPreviewRequest": { + "type": "object", + "description": "Request to preview metadata from a plugin", + "required": [ + "pluginId", + "externalId" + ], + "properties": { + "externalId": { "type": "string", - "description": "Default reading direction for books in this library (ltr, rtl, ttb or webtoon)", - "example": "ltr" - }, - "description": { - "type": [ - "string", - "null" - ], - "description": "Optional description", - "example": "My comic book collection" - }, - "excludedPatterns": { - "type": [ - "string", - "null" - ], - "description": "Excluded path patterns (newline-separated, e.g., \".DS_Store\\nThumbs.db\")", - "example": ".DS_Store\nThumbs.db" + "description": "External ID from the plugin's search results" }, - "id": { + "pluginId": { "type": "string", "format": "uuid", - "description": "Library unique identifier", - "example": "550e8400-e29b-41d4-a716-446655440000" - }, - "isActive": { - "type": "boolean", - "description": "Whether the library is active", - "example": true + "description": "Plugin ID to fetch metadata from" + } + } + }, + "MetadataPreviewResponse": { + "type": "object", + "description": "Response containing metadata preview", + "required": [ + "fields", + "summary", + "pluginId", + "pluginName", + "externalId" + ], + "properties": { + "externalId": { + "type": "string", + "description": "External ID used" }, - "lastScannedAt": { + "externalUrl": { "type": [ "string", "null" ], - "format": "date-time", - "description": "When the library was last scanned", - "example": "2024-01-15T10:30:00Z" - }, - "name": { - "type": "string", - "description": "Library name", - "example": "Comics" - }, - "numberConfig": { - "description": "Number strategy-specific configuration (JSON)" + "description": "External URL (link to provider's page)" }, - "numberStrategy": { - "$ref": "#/components/schemas/NumberStrategy", - "description": "Book number strategy (file_order, metadata, filename, smart)" + "fields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetadataFieldPreview" + }, + "description": "Field-by-field preview" }, - "path": { + "pluginId": { "type": "string", - "description": "Filesystem path to the library root", - "example": "/media/comics" + "format": "uuid", + "description": "Plugin that provided the metadata" }, - "scanningConfig": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ScanningConfigDto", - "description": "Scanning configuration for scheduled scans" - } - ] + "pluginName": { + "type": "string", + "description": "Plugin name" }, - "seriesConfig": { - "description": "Strategy-specific configuration (JSON)" + "summary": { + "$ref": "#/components/schemas/PreviewSummary", + "description": "Summary counts" + } + } + }, + "MetricsCleanupResponse": { + "type": "object", + "description": "Response for cleanup operation", + "required": [ + "deleted_count", + "retention_days" + ], + "properties": { + "deleted_count": { + "type": "integer", + "format": "int64", + "description": "Number of metric records deleted", + "example": 500, + "minimum": 0 }, - "seriesCount": { + "oldest_remaining": { "type": [ - "integer", + "string", "null" ], - "format": "int64", - "description": "Total number of series in this library", - "example": 85 - }, - "seriesStrategy": { - "$ref": "#/components/schemas/SeriesStrategy", - "description": "Series detection strategy (series_volume, series_volume_chapter, flat, etc.)" + "format": "date-time", + "description": "Timestamp of oldest remaining record" }, - "updatedAt": { + "retention_days": { "type": "string", - "format": "date-time", - "description": "When the library was last updated", - "example": "2024-01-15T10:30:00Z" + "description": "Current retention setting", + "example": "30" } } }, - "LibraryMetricsDto": { + "MetricsDto": { "type": "object", - "description": "Metrics for a single library", + "description": "Application metrics response", "required": [ - "id", - "name", + "library_count", "series_count", "book_count", - "total_size" + "total_book_size", + "user_count", + "database_size", + "page_count", + "libraries" ], "properties": { "book_count": { "type": "integer", "format": "int64", - "description": "Number of books in this library", - "example": 1200 + "description": "Total number of books across all libraries", + "example": 3500 }, - "id": { - "type": "string", - "format": "uuid", - "description": "Library ID", - "example": "550e8400-e29b-41d4-a716-446655440000" + "database_size": { + "type": "integer", + "format": "int64", + "description": "Database size in bytes (approximate)", + "example": 10485760 }, - "name": { - "type": "string", - "description": "Library name", - "example": "Comics" + "libraries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryMetricsDto" + }, + "description": "Breakdown by library" + }, + "library_count": { + "type": "integer", + "format": "int64", + "description": "Total number of libraries in the system", + "example": 5 + }, + "page_count": { + "type": "integer", + "format": "int64", + "description": "Number of pages across all books", + "example": 175000 }, "series_count": { "type": "integer", "format": "int64", - "description": "Number of series in this library", - "example": 45 + "description": "Total number of series across all libraries", + "example": 150 }, - "total_size": { + "total_book_size": { "type": "integer", "format": "int64", - "description": "Total size of books in bytes (approx. 15GB)", - "example": "15728640000" + "description": "Total size of all books in bytes (approx. 50GB)", + "example": "52428800000" + }, + "user_count": { + "type": "integer", + "format": "int64", + "description": "Number of registered users", + "example": 12 } } }, - "LinkProperties": { + "MetricsNukeResponse": { "type": "object", - "description": "Additional properties that can be attached to links", + "description": "Response for nuke (delete all) operation", + "required": [ + "deleted_count" + ], "properties": { - "numberOfItems": { + "deleted_count": { + "type": "integer", + "format": "int64", + "description": "Number of metric records deleted", + "example": 15000, + "minimum": 0 + } + } + }, + "ModifySeriesSharingTagRequest": { + "type": "object", + "description": "Add/remove single sharing tag from series request", + "required": [ + "sharingTagId" + ], + "properties": { + "sharingTagId": { + "type": "string", + "format": "uuid", + "description": "Sharing tag ID", + "example": "550e8400-e29b-41d4-a716-446655440000" + } + } + }, + "NumberStrategy": { + "type": "string", + "description": "Book number strategy type for determining book ordering numbers\n\nDetermines how individual book numbers are resolved for sorting and display.", + "enum": [ + "file_order", + "metadata", + "filename", + "smart" + ] + }, + "Opds2Feed": { + "type": "object", + "description": "OPDS 2.0 Feed\n\nThe main container for OPDS 2.0 data. A feed contains metadata,\nlinks, and one of: navigation, publications, or groups.", + "required": [ + "metadata", + "links" + ], + "properties": { + "groups": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/Group" + }, + "description": "Groups containing multiple collections" + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Opds2Link" + }, + "description": "Feed-level links (self, search, start, etc.)" + }, + "metadata": { + "$ref": "#/components/schemas/FeedMetadata", + "description": "Feed metadata (title, pagination, etc.)" + }, + "navigation": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/Opds2Link" + }, + "description": "Navigation links (for navigation feeds)" + }, + "publications": { "type": [ - "integer", + "array", "null" ], - "format": "int64", - "description": "Number of items in the linked collection" + "items": { + "$ref": "#/components/schemas/Publication" + }, + "description": "Publication entries (for acquisition feeds)" } } }, - "ListDuplicatesResponse": { + "Opds2Link": { "type": "object", - "description": "Response for listing duplicates", + "description": "OPDS 2.0 Link Object\n\nRepresents a link in an OPDS 2.0 feed, based on the Web Publication Manifest model.\nLinks can be templated using URI templates (RFC 6570).", "required": [ - "duplicates", - "total_groups", - "total_duplicate_books" + "href" ], "properties": { - "duplicates": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DuplicateGroup" - }, - "description": "List of duplicate groups" + "href": { + "type": "string", + "description": "The URI or URI template for the link" }, - "total_duplicate_books": { - "type": "integer", - "description": "Total number of books that are duplicates", - "example": 15, - "minimum": 0 + "properties": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LinkProperties", + "description": "Additional properties for the link" + } + ] }, - "total_groups": { - "type": "integer", - "description": "Total number of duplicate groups", - "example": 5, - "minimum": 0 - } - } - }, - "ListSettingsQuery": { - "type": "object", - "description": "Query parameters for listing settings", - "properties": { - "category": { + "rel": { "type": [ "string", "null" ], - "description": "Filter settings by category", - "example": "scanning" - } - } - }, - "LoginRequest": { - "type": "object", - "description": "Login request", - "required": [ - "username", - "password" - ], - "properties": { - "password": { - "type": "string", - "description": "Password", - "example": "password123" + "description": "Relation type (e.g., \"self\", \"search\", \"http://opds-spec.org/acquisition\")" }, - "username": { - "type": "string", - "description": "Username or email", - "example": "admin" + "templated": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the href is a URI template" + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Human-readable title for the link" + }, + "type": { + "type": [ + "string", + "null" + ], + "description": "Media type of the linked resource" } } }, - "LoginResponse": { + "OrphanStatsDto": { "type": "object", - "description": "Login response with JWT token", + "description": "Statistics about orphaned files in the system", "required": [ - "accessToken", - "tokenType", - "expiresIn", - "user" + "orphaned_thumbnails", + "orphaned_covers", + "total_size_bytes" ], "properties": { - "accessToken": { - "type": "string", - "description": "JWT access token", - "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ" + "files": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/OrphanedFileDto" + }, + "description": "List of orphaned files with details" }, - "expiresIn": { + "orphaned_covers": { "type": "integer", - "format": "int64", - "description": "Token expiry in seconds", - "example": 86400, + "format": "int32", + "description": "Number of orphaned cover files (no matching series in database)", + "example": 5, "minimum": 0 }, - "tokenType": { - "type": "string", - "description": "Token type (always \"Bearer\")", - "example": "Bearer" - }, - "user": { - "$ref": "#/components/schemas/UserInfo", - "description": "User information" - } - } - }, - "MarkReadResponse": { - "type": "object", - "description": "Response for bulk mark as read/unread operations", - "required": [ - "count", - "message" - ], - "properties": { - "count": { + "orphaned_thumbnails": { "type": "integer", - "description": "Number of books affected", - "example": 5, + "format": "int32", + "description": "Number of orphaned thumbnail files (no matching book in database)", + "example": 42, "minimum": 0 }, - "message": { - "type": "string", - "description": "Message describing the operation", - "example": "Marked 5 books as read" + "total_size_bytes": { + "type": "integer", + "format": "int64", + "description": "Total size of all orphaned files in bytes", + "example": 1073741824, + "minimum": 0 } } }, - "MessageResponse": { + "OrphanStatsQuery": { "type": "object", - "required": [ - "message" - ], + "description": "Query parameters for orphan stats endpoint", "properties": { - "message": { - "type": "string", - "description": "Response message", - "example": "Task 550e8400-e29b-41d4-a716-446655440000 cancelled" + "includeFiles": { + "type": "boolean", + "description": "If true, include the full list of orphaned files in the response" } } }, - "MetadataLocks": { + "OrphanedFileDto": { "type": "object", - "description": "Lock states for all lockable metadata fields", + "description": "Information about a single orphaned file", "required": [ - "title", - "titleSort", - "summary", - "publisher", - "imprint", - "status", - "ageRating", - "language", - "readingDirection", - "year", - "totalBookCount", - "genres", - "tags", - "customMetadata" + "path", + "size_bytes", + "file_type" ], "properties": { - "ageRating": { - "type": "boolean", - "description": "Whether the age_rating field is locked", - "example": false - }, - "customMetadata": { - "type": "boolean", - "description": "Whether the custom_metadata field is locked", - "example": false - }, - "genres": { - "type": "boolean", - "description": "Whether the genres are locked", - "example": false - }, - "imprint": { - "type": "boolean", - "description": "Whether the imprint field is locked", - "example": false - }, - "language": { - "type": "boolean", - "description": "Whether the language field is locked", - "example": false - }, - "publisher": { - "type": "boolean", - "description": "Whether the publisher field is locked", - "example": false - }, - "readingDirection": { - "type": "boolean", - "description": "Whether the reading_direction field is locked", - "example": false - }, - "status": { - "type": "boolean", - "description": "Whether the status field is locked", - "example": false - }, - "summary": { - "type": "boolean", - "description": "Whether the summary field is locked", - "example": true - }, - "tags": { - "type": "boolean", - "description": "Whether the tags are locked", - "example": false - }, - "title": { - "type": "boolean", - "description": "Whether the title field is locked", - "example": false + "entity_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "The entity UUID extracted from the filename", + "example": "550e8400-e29b-41d4-a716-446655440000" }, - "titleSort": { - "type": "boolean", - "description": "Whether the title_sort field is locked", - "example": false + "file_type": { + "type": "string", + "description": "Type of file: \"thumbnail\" or \"cover\"", + "example": "thumbnail" }, - "totalBookCount": { - "type": "boolean", - "description": "Whether the total_book_count field is locked", - "example": false + "path": { + "type": "string", + "description": "Path to the orphaned file (relative to data directory)", + "example": "thumbnails/books/55/550e8400-e29b-41d4-a716-446655440000.jpg" }, - "year": { - "type": "boolean", - "description": "Whether the year field is locked", - "example": false + "size_bytes": { + "type": "integer", + "format": "int64", + "description": "Size of the file in bytes", + "example": 25600, + "minimum": 0 } } }, - "MetricsCleanupResponse": { + "PageDto": { "type": "object", - "description": "Response for cleanup operation", + "description": "Page data transfer object", "required": [ - "deleted_count", - "retention_days" + "id", + "bookId", + "pageNumber", + "fileName", + "fileFormat", + "fileSize" ], "properties": { - "deleted_count": { + "bookId": { + "type": "string", + "format": "uuid", + "description": "Book this page belongs to", + "example": "550e8400-e29b-41d4-a716-446655440001" + }, + "fileFormat": { + "type": "string", + "description": "Image format (jpg, png, webp, etc.)", + "example": "jpg" + }, + "fileName": { + "type": "string", + "description": "Original filename within the archive", + "example": "page_001.jpg" + }, + "fileSize": { "type": "integer", "format": "int64", - "description": "Number of metric records deleted", - "example": 500, - "minimum": 0 + "description": "File size in bytes", + "example": 524288 }, - "oldest_remaining": { + "height": { "type": [ - "string", + "integer", "null" ], - "format": "date-time", - "description": "Timestamp of oldest remaining record" + "format": "int32", + "description": "Image height in pixels", + "example": 1800 }, - "retention_days": { + "id": { "type": "string", - "description": "Current retention setting", - "example": "30" + "format": "uuid", + "description": "Unique page identifier", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "pageNumber": { + "type": "integer", + "format": "int32", + "description": "Page number within the book (0-indexed)", + "example": 0 + }, + "width": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Image width in pixels", + "example": 1200 } } }, - "MetricsDto": { + "PaginatedResponse": { "type": "object", - "description": "Application metrics response", + "description": "Generic paginated response wrapper with HATEOAS links", "required": [ - "library_count", - "series_count", - "book_count", - "total_book_size", - "user_count", - "database_size", - "page_count", - "libraries" + "data", + "page", + "pageSize", + "total", + "totalPages", + "links" ], "properties": { - "book_count": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookDto" + }, + "description": "The data items for this page" + }, + "links": { + "$ref": "#/components/schemas/PaginationLinks", + "description": "HATEOAS navigation links" + }, + "page": { "type": "integer", "format": "int64", - "description": "Total number of books across all libraries", - "example": 3500 + "description": "Current page number (1-indexed)", + "example": 1, + "minimum": 0 }, - "database_size": { + "pageSize": { "type": "integer", "format": "int64", - "description": "Database size in bytes (approximate)", - "example": 10485760 - }, - "libraries": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LibraryMetricsDto" - }, - "description": "Breakdown by library" + "description": "Number of items per page", + "example": 50, + "minimum": 0 }, - "library_count": { + "total": { "type": "integer", "format": "int64", - "description": "Total number of libraries in the system", - "example": 5 + "description": "Total number of items across all pages", + "example": 150, + "minimum": 0 }, - "page_count": { + "totalPages": { "type": "integer", "format": "int64", - "description": "Number of pages across all books", - "example": 175000 + "description": "Total number of pages", + "example": 3, + "minimum": 0 + } + } + }, + "PaginatedResponse_ApiKeyDto": { + "type": "object", + "description": "Generic paginated response wrapper with HATEOAS links", + "required": [ + "data", + "page", + "pageSize", + "total", + "totalPages", + "links" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "description": "API key data transfer object", + "required": [ + "id", + "userId", + "name", + "keyPrefix", + "permissions", + "isActive", + "createdAt", + "updatedAt" + ], + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the key was created", + "example": "2024-01-01T00:00:00Z" + }, + "expiresAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the key expires (if set)", + "example": "2025-12-31T23:59:59Z" + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Unique API key identifier", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "isActive": { + "type": "boolean", + "description": "Whether the key is currently active", + "example": true + }, + "keyPrefix": { + "type": "string", + "description": "Prefix of the key for identification", + "example": "cdx_a1b2c3" + }, + "lastUsedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the key was last used", + "example": "2024-01-15T10:30:00Z" + }, + "name": { + "type": "string", + "description": "Human-readable name for the key", + "example": "Mobile App Key" + }, + "permissions": { + "description": "Permissions granted to this key" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the key was last updated", + "example": "2024-01-15T10:30:00Z" + }, + "userId": { + "type": "string", + "format": "uuid", + "description": "Owner user ID", + "example": "550e8400-e29b-41d4-a716-446655440001" + } + } + }, + "description": "The data items for this page" + }, + "links": { + "$ref": "#/components/schemas/PaginationLinks", + "description": "HATEOAS navigation links" }, - "series_count": { + "page": { "type": "integer", "format": "int64", - "description": "Total number of series across all libraries", - "example": 150 + "description": "Current page number (1-indexed)", + "example": 1, + "minimum": 0 }, - "total_book_size": { + "pageSize": { "type": "integer", "format": "int64", - "description": "Total size of all books in bytes (approx. 50GB)", - "example": "52428800000" + "description": "Number of items per page", + "example": 50, + "minimum": 0 }, - "user_count": { + "total": { "type": "integer", "format": "int64", - "description": "Number of registered users", - "example": 12 - } - } - }, - "MetricsNukeResponse": { - "type": "object", - "description": "Response for nuke (delete all) operation", - "required": [ - "deleted_count" - ], - "properties": { - "deleted_count": { + "description": "Total number of items across all pages", + "example": 150, + "minimum": 0 + }, + "totalPages": { "type": "integer", "format": "int64", - "description": "Number of metric records deleted", - "example": 15000, + "description": "Total number of pages", + "example": 3, "minimum": 0 } } }, - "ModifySeriesSharingTagRequest": { - "type": "object", - "description": "Add/remove single sharing tag from series request", - "required": [ - "sharingTagId" - ], - "properties": { - "sharingTagId": { - "type": "string", - "format": "uuid", - "description": "Sharing tag ID", - "example": "550e8400-e29b-41d4-a716-446655440000" - } - } - }, - "NumberStrategy": { - "type": "string", - "description": "Book number strategy type for determining book ordering numbers\n\nDetermines how individual book numbers are resolved for sorting and display.", - "enum": [ - "file_order", - "metadata", - "filename", - "smart" - ] - }, - "Opds2Feed": { + "PaginatedResponse_BookDto": { "type": "object", - "description": "OPDS 2.0 Feed\n\nThe main container for OPDS 2.0 data. A feed contains metadata,\nlinks, and one of: navigation, publications, or groups.", + "description": "Generic paginated response wrapper with HATEOAS links", "required": [ - "metadata", + "data", + "page", + "pageSize", + "total", + "totalPages", "links" ], "properties": { - "groups": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/components/schemas/Group" - }, - "description": "Groups containing multiple collections" - }, - "links": { + "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Opds2Link" - }, - "description": "Feed-level links (self, search, start, etc.)" - }, - "metadata": { - "$ref": "#/components/schemas/FeedMetadata", - "description": "Feed metadata (title, pagination, etc.)" - }, - "navigation": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/components/schemas/Opds2Link" - }, - "description": "Navigation links (for navigation feeds)" - }, - "publications": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/components/schemas/Publication" - }, - "description": "Publication entries (for acquisition feeds)" - } - } - }, - "Opds2Link": { - "type": "object", - "description": "OPDS 2.0 Link Object\n\nRepresents a link in an OPDS 2.0 feed, based on the Web Publication Manifest model.\nLinks can be templated using URI templates (RFC 6570).", - "required": [ - "href" - ], - "properties": { - "href": { - "type": "string", - "description": "The URI or URI template for the link" - }, - "properties": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/LinkProperties", - "description": "Additional properties for the link" + "type": "object", + "description": "Book data transfer object", + "required": [ + "id", + "libraryId", + "libraryName", + "seriesId", + "seriesName", + "title", + "filePath", + "fileFormat", + "fileSize", + "fileHash", + "pageCount", + "createdAt", + "updatedAt", + "deleted" + ], + "properties": { + "analysisError": { + "type": [ + "string", + "null" + ], + "description": "Error message if book analysis failed", + "example": "Failed to parse CBZ: invalid archive" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the book was added to the library", + "example": "2024-01-01T00:00:00Z" + }, + "deleted": { + "type": "boolean", + "description": "Whether the book has been soft-deleted", + "example": false + }, + "fileFormat": { + "type": "string", + "description": "File format (cbz, cbr, epub, pdf)", + "example": "cbz" + }, + "fileHash": { + "type": "string", + "description": "File hash for deduplication", + "example": "a1b2c3d4e5f6g7h8i9j0" + }, + "filePath": { + "type": "string", + "description": "Filesystem path to the book file", + "example": "/media/comics/Batman/Batman - Year One 001.cbz" + }, + "fileSize": { + "type": "integer", + "format": "int64", + "description": "File size in bytes", + "example": 52428800 + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Book unique identifier", + "example": "550e8400-e29b-41d4-a716-446655440001" + }, + "libraryId": { + "type": "string", + "format": "uuid", + "description": "Library this book belongs to", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "libraryName": { + "type": "string", + "description": "Name of the library", + "example": "Comics" + }, + "number": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Book number within the series", + "example": 1 + }, + "pageCount": { + "type": "integer", + "format": "int32", + "description": "Number of pages in the book", + "example": 32 + }, + "readProgress": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ReadProgressResponse", + "description": "User's read progress for this book" + } + ] + }, + "readingDirection": { + "type": [ + "string", + "null" + ], + "description": "Effective reading direction (from series metadata, or library default if not set)\nValues: ltr, rtl, ttb or webtoon", + "example": "ltr" + }, + "seriesId": { + "type": "string", + "format": "uuid", + "description": "Series this book belongs to", + "example": "550e8400-e29b-41d4-a716-446655440002" + }, + "seriesName": { + "type": "string", + "description": "Name of the series", + "example": "Batman: Year One" + }, + "title": { + "type": "string", + "description": "Book title", + "example": "Batman: Year One #1" + }, + "titleSort": { + "type": [ + "string", + "null" + ], + "description": "Title used for sorting (title_sort field)", + "example": "batman year one 001" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the book was last updated", + "example": "2024-01-15T10:30:00Z" + } } - ] - }, - "rel": { - "type": [ - "string", - "null" - ], - "description": "Relation type (e.g., \"self\", \"search\", \"http://opds-spec.org/acquisition\")" - }, - "templated": { - "type": [ - "boolean", - "null" - ], - "description": "Whether the href is a URI template" - }, - "title": { - "type": [ - "string", - "null" - ], - "description": "Human-readable title for the link" - }, - "type": { - "type": [ - "string", - "null" - ], - "description": "Media type of the linked resource" - } - } - }, - "OrphanStatsDto": { - "type": "object", - "description": "Statistics about orphaned files in the system", - "required": [ - "orphaned_thumbnails", - "orphaned_covers", - "total_size_bytes" - ], - "properties": { - "files": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/components/schemas/OrphanedFileDto" }, - "description": "List of orphaned files with details" + "description": "The data items for this page" }, - "orphaned_covers": { + "links": { + "$ref": "#/components/schemas/PaginationLinks", + "description": "HATEOAS navigation links" + }, + "page": { "type": "integer", - "format": "int32", - "description": "Number of orphaned cover files (no matching series in database)", - "example": 5, + "format": "int64", + "description": "Current page number (1-indexed)", + "example": 1, "minimum": 0 }, - "orphaned_thumbnails": { + "pageSize": { "type": "integer", - "format": "int32", - "description": "Number of orphaned thumbnail files (no matching book in database)", - "example": 42, + "format": "int64", + "description": "Number of items per page", + "example": 50, "minimum": 0 }, - "total_size_bytes": { + "total": { "type": "integer", "format": "int64", - "description": "Total size of all orphaned files in bytes", - "example": 1073741824, + "description": "Total number of items across all pages", + "example": 150, "minimum": 0 - } - } - }, - "OrphanStatsQuery": { - "type": "object", - "description": "Query parameters for orphan stats endpoint", - "properties": { - "includeFiles": { - "type": "boolean", - "description": "If true, include the full list of orphaned files in the response" - } - } - }, - "OrphanedFileDto": { - "type": "object", - "description": "Information about a single orphaned file", - "required": [ - "path", - "size_bytes", - "file_type" - ], - "properties": { - "entity_id": { - "type": [ - "string", - "null" - ], - "format": "uuid", - "description": "The entity UUID extracted from the filename", - "example": "550e8400-e29b-41d4-a716-446655440000" - }, - "file_type": { - "type": "string", - "description": "Type of file: \"thumbnail\" or \"cover\"", - "example": "thumbnail" - }, - "path": { - "type": "string", - "description": "Path to the orphaned file (relative to data directory)", - "example": "thumbnails/books/55/550e8400-e29b-41d4-a716-446655440000.jpg" }, - "size_bytes": { + "totalPages": { "type": "integer", "format": "int64", - "description": "Size of the file in bytes", - "example": 25600, + "description": "Total number of pages", + "example": 3, "minimum": 0 } } }, - "PageDto": { + "PaginatedResponse_GenreDto": { "type": "object", - "description": "Page data transfer object", + "description": "Generic paginated response wrapper with HATEOAS links", "required": [ - "id", - "bookId", - "pageNumber", - "fileName", - "fileFormat", - "fileSize" + "data", + "page", + "pageSize", + "total", + "totalPages", + "links" ], "properties": { - "bookId": { - "type": "string", - "format": "uuid", - "description": "Book this page belongs to", - "example": "550e8400-e29b-41d4-a716-446655440001" - }, - "fileFormat": { - "type": "string", - "description": "Image format (jpg, png, webp, etc.)", - "example": "jpg" + "data": { + "type": "array", + "items": { + "type": "object", + "description": "Genre data transfer object", + "required": [ + "id", + "name", + "createdAt" + ], + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the genre was created", + "example": "2024-01-01T00:00:00Z" + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Genre ID", + "example": "550e8400-e29b-41d4-a716-446655440010" + }, + "name": { + "type": "string", + "description": "Genre name", + "example": "Action" + }, + "seriesCount": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Number of series with this genre", + "example": 42, + "minimum": 0 + } + } + }, + "description": "The data items for this page" }, - "fileName": { - "type": "string", - "description": "Original filename within the archive", - "example": "page_001.jpg" + "links": { + "$ref": "#/components/schemas/PaginationLinks", + "description": "HATEOAS navigation links" }, - "fileSize": { + "page": { "type": "integer", "format": "int64", - "description": "File size in bytes", - "example": 524288 - }, - "height": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Image height in pixels", - "example": 1800 - }, - "id": { - "type": "string", - "format": "uuid", - "description": "Unique page identifier", - "example": "550e8400-e29b-41d4-a716-446655440000" + "description": "Current page number (1-indexed)", + "example": 1, + "minimum": 0 }, - "pageNumber": { + "pageSize": { "type": "integer", - "format": "int32", - "description": "Page number within the book (0-indexed)", - "example": 0 + "format": "int64", + "description": "Number of items per page", + "example": 50, + "minimum": 0 }, - "width": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Image width in pixels", - "example": 1200 + "total": { + "type": "integer", + "format": "int64", + "description": "Total number of items across all pages", + "example": 150, + "minimum": 0 + }, + "totalPages": { + "type": "integer", + "format": "int64", + "description": "Total number of pages", + "example": 3, + "minimum": 0 } } }, - "PaginatedResponse": { + "PaginatedResponse_LibraryDto": { "type": "object", "description": "Generic paginated response wrapper with HATEOAS links", "required": [ @@ -17517,7 +20286,150 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/BookDto" + "type": "object", + "description": "Library data transfer object", + "required": [ + "id", + "name", + "path", + "isActive", + "seriesStrategy", + "bookStrategy", + "numberStrategy", + "createdAt", + "updatedAt", + "defaultReadingDirection" + ], + "properties": { + "allowedFormats": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Allowed file formats (e.g., [\"CBZ\", \"CBR\", \"EPUB\"])", + "example": [ + "CBZ", + "CBR", + "PDF" + ] + }, + "bookConfig": { + "description": "Book strategy-specific configuration (JSON)" + }, + "bookCount": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Total number of books in this library", + "example": 1250 + }, + "bookStrategy": { + "$ref": "#/components/schemas/BookStrategy", + "description": "Book naming strategy (filename, metadata_first, smart, series_name)" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the library was created", + "example": "2024-01-01T00:00:00Z" + }, + "defaultReadingDirection": { + "type": "string", + "description": "Default reading direction for books in this library (ltr, rtl, ttb or webtoon)", + "example": "ltr" + }, + "description": { + "type": [ + "string", + "null" + ], + "description": "Optional description", + "example": "My comic book collection" + }, + "excludedPatterns": { + "type": [ + "string", + "null" + ], + "description": "Excluded path patterns (newline-separated, e.g., \".DS_Store\\nThumbs.db\")", + "example": ".DS_Store\nThumbs.db" + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Library unique identifier", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "isActive": { + "type": "boolean", + "description": "Whether the library is active", + "example": true + }, + "lastScannedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the library was last scanned", + "example": "2024-01-15T10:30:00Z" + }, + "name": { + "type": "string", + "description": "Library name", + "example": "Comics" + }, + "numberConfig": { + "description": "Number strategy-specific configuration (JSON)" + }, + "numberStrategy": { + "$ref": "#/components/schemas/NumberStrategy", + "description": "Book number strategy (file_order, metadata, filename, smart)" + }, + "path": { + "type": "string", + "description": "Filesystem path to the library root", + "example": "/media/comics" + }, + "scanningConfig": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ScanningConfigDto", + "description": "Scanning configuration for scheduled scans" + } + ] + }, + "seriesConfig": { + "description": "Strategy-specific configuration (JSON)" + }, + "seriesCount": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Total number of series in this library", + "example": 85 + }, + "seriesStrategy": { + "$ref": "#/components/schemas/SeriesStrategy", + "description": "Series detection strategy (series_volume, series_volume_chapter, flat, etc.)" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the library was last updated", + "example": "2024-01-15T10:30:00Z" + } + } }, "description": "The data items for this page" }, @@ -17555,7 +20467,7 @@ } } }, - "PaginatedResponse_ApiKeyDto": { + "PaginatedResponse_SeriesDto": { "type": "object", "description": "Generic paginated response wrapper with HATEOAS links", "required": [ @@ -17571,77 +20483,122 @@ "type": "array", "items": { "type": "object", - "description": "API key data transfer object", + "description": "Series data transfer object", "required": [ "id", - "userId", - "name", - "keyPrefix", - "permissions", - "isActive", + "libraryId", + "libraryName", + "title", + "bookCount", "createdAt", "updatedAt" ], "properties": { + "bookCount": { + "type": "integer", + "format": "int64", + "description": "Total number of books in this series", + "example": 4 + }, "createdAt": { "type": "string", "format": "date-time", - "description": "When the key was created", + "description": "When the series was created", "example": "2024-01-01T00:00:00Z" }, - "expiresAt": { + "hasCustomCover": { "type": [ - "string", + "boolean", "null" ], - "format": "date-time", - "description": "When the key expires (if set)", - "example": "2025-12-31T23:59:59Z" + "description": "Whether the series has a custom cover uploaded", + "example": false }, "id": { "type": "string", "format": "uuid", - "description": "Unique API key identifier", - "example": "550e8400-e29b-41d4-a716-446655440000" + "description": "Series unique identifier", + "example": "550e8400-e29b-41d4-a716-446655440002" }, - "isActive": { - "type": "boolean", - "description": "Whether the key is currently active", - "example": true + "libraryId": { + "type": "string", + "format": "uuid", + "description": "Library unique identifier", + "example": "550e8400-e29b-41d4-a716-446655440000" }, - "keyPrefix": { + "libraryName": { "type": "string", - "description": "Prefix of the key for identification", - "example": "cdx_a1b2c3" + "description": "Name of the library this series belongs to", + "example": "Comics" }, - "lastUsedAt": { + "path": { "type": [ "string", "null" ], - "format": "date-time", - "description": "When the key was last used", - "example": "2024-01-15T10:30:00Z" + "description": "Filesystem path to the series directory", + "example": "/media/comics/Batman - Year One" }, - "name": { + "publisher": { + "type": [ + "string", + "null" + ], + "description": "Publisher name", + "example": "DC Comics" + }, + "selectedCoverSource": { + "type": [ + "string", + "null" + ], + "description": "Selected cover source (e.g., \"first_book\", \"custom\")", + "example": "first_book" + }, + "summary": { + "type": [ + "string", + "null" + ], + "description": "Summary/description from series_metadata", + "example": "The definitive origin story of Batman, following Bruce Wayne's first year as a vigilante." + }, + "title": { "type": "string", - "description": "Human-readable name for the key", - "example": "Mobile App Key" + "description": "Series title from series_metadata", + "example": "Batman: Year One" }, - "permissions": { - "description": "Permissions granted to this key" + "titleSort": { + "type": [ + "string", + "null" + ], + "description": "Sort title from series_metadata (for ordering)", + "example": "batman year one" + }, + "unreadCount": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Number of unread books in this series (user-specific)", + "example": 2 }, "updatedAt": { "type": "string", "format": "date-time", - "description": "When the key was last updated", + "description": "When the series was last updated", "example": "2024-01-15T10:30:00Z" }, - "userId": { - "type": "string", - "format": "uuid", - "description": "Owner user ID", - "example": "550e8400-e29b-41d4-a716-446655440001" + "year": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Release year", + "example": 1987 } } }, @@ -17681,7 +20638,7 @@ } } }, - "PaginatedResponse_BookDto": { + "PaginatedResponse_SharingTagDto": { "type": "object", "description": "Generic paginated response wrapper with HATEOAS links", "required": [ @@ -17697,144 +20654,60 @@ "type": "array", "items": { "type": "object", - "description": "Book data transfer object", + "description": "Sharing tag data transfer object", "required": [ "id", - "libraryId", - "libraryName", - "seriesId", - "seriesName", - "title", - "filePath", - "fileFormat", - "fileSize", - "fileHash", - "pageCount", + "name", + "seriesCount", + "userCount", "createdAt", - "updatedAt", - "deleted" + "updatedAt" ], "properties": { - "analysisError": { - "type": [ - "string", - "null" - ], - "description": "Error message if book analysis failed", - "example": "Failed to parse CBZ: invalid archive" - }, "createdAt": { "type": "string", "format": "date-time", - "description": "When the book was added to the library", + "description": "Creation timestamp", "example": "2024-01-01T00:00:00Z" }, - "deleted": { - "type": "boolean", - "description": "Whether the book has been soft-deleted", - "example": false - }, - "fileFormat": { - "type": "string", - "description": "File format (cbz, cbr, epub, pdf)", - "example": "cbz" - }, - "fileHash": { - "type": "string", - "description": "File hash for deduplication", - "example": "a1b2c3d4e5f6g7h8i9j0" - }, - "filePath": { - "type": "string", - "description": "Filesystem path to the book file", - "example": "/media/comics/Batman/Batman - Year One 001.cbz" - }, - "fileSize": { - "type": "integer", - "format": "int64", - "description": "File size in bytes", - "example": 52428800 - }, - "id": { - "type": "string", - "format": "uuid", - "description": "Book unique identifier", - "example": "550e8400-e29b-41d4-a716-446655440001" - }, - "libraryId": { - "type": "string", - "format": "uuid", - "description": "Library this book belongs to", - "example": "550e8400-e29b-41d4-a716-446655440000" - }, - "libraryName": { - "type": "string", - "description": "Name of the library", - "example": "Comics" - }, - "number": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Book number within the series", - "example": 1 - }, - "pageCount": { - "type": "integer", - "format": "int32", - "description": "Number of pages in the book", - "example": 32 - }, - "readProgress": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ReadProgressResponse", - "description": "User's read progress for this book" - } - ] - }, - "readingDirection": { + "description": { "type": [ "string", "null" ], - "description": "Effective reading direction (from series metadata, or library default if not set)\nValues: ltr, rtl, ttb or webtoon", - "example": "ltr" + "description": "Optional description", + "example": "Content appropriate for children" }, - "seriesId": { + "id": { "type": "string", "format": "uuid", - "description": "Series this book belongs to", - "example": "550e8400-e29b-41d4-a716-446655440002" - }, - "seriesName": { - "type": "string", - "description": "Name of the series", - "example": "Batman: Year One" + "description": "Unique sharing tag identifier", + "example": "550e8400-e29b-41d4-a716-446655440000" }, - "title": { + "name": { "type": "string", - "description": "Book title", - "example": "Batman: Year One #1" + "description": "Display name of the sharing tag", + "example": "Kids Content" }, - "titleSort": { - "type": [ - "string", - "null" - ], - "description": "Title used for sorting (title_sort field)", - "example": "batman year one 001" + "seriesCount": { + "type": "integer", + "format": "int64", + "description": "Number of series tagged with this sharing tag", + "example": 42, + "minimum": 0 }, "updatedAt": { "type": "string", "format": "date-time", - "description": "When the book was last updated", + "description": "Last update timestamp", "example": "2024-01-15T10:30:00Z" + }, + "userCount": { + "type": "integer", + "format": "int64", + "description": "Number of users with grants for this sharing tag", + "example": 5, + "minimum": 0 } } }, @@ -17874,7 +20747,7 @@ } } }, - "PaginatedResponse_GenreDto": { + "PaginatedResponse_TagDto": { "type": "object", "description": "Generic paginated response wrapper with HATEOAS links", "required": [ @@ -17890,7 +20763,7 @@ "type": "array", "items": { "type": "object", - "description": "Genre data transfer object", + "description": "Tag data transfer object", "required": [ "id", "name", @@ -17900,19 +20773,19 @@ "createdAt": { "type": "string", "format": "date-time", - "description": "When the genre was created", + "description": "When the tag was created", "example": "2024-01-01T00:00:00Z" }, "id": { "type": "string", "format": "uuid", - "description": "Genre ID", - "example": "550e8400-e29b-41d4-a716-446655440010" + "description": "Tag ID", + "example": "550e8400-e29b-41d4-a716-446655440020" }, "name": { "type": "string", - "description": "Genre name", - "example": "Action" + "description": "Tag name", + "example": "Completed" }, "seriesCount": { "type": [ @@ -17920,8 +20793,8 @@ "null" ], "format": "int64", - "description": "Number of series with this genre", - "example": 42, + "description": "Number of series with this tag", + "example": 15, "minimum": 0 } } @@ -17962,7 +20835,7 @@ } } }, - "PaginatedResponse_LibraryDto": { + "PaginatedResponse_UserDto": { "type": "object", "description": "Generic paginated response wrapper with HATEOAS links", "required": [ @@ -17978,989 +20851,1482 @@ "type": "array", "items": { "type": "object", - "description": "Library data transfer object", + "description": "User data transfer object", "required": [ "id", - "name", - "path", + "username", + "email", + "role", + "permissions", "isActive", - "seriesStrategy", - "bookStrategy", - "numberStrategy", "createdAt", - "updatedAt", - "defaultReadingDirection" + "updatedAt" ], "properties": { - "allowedFormats": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - }, - "description": "Allowed file formats (e.g., [\"CBZ\", \"CBR\", \"EPUB\"])", - "example": [ - "CBZ", - "CBR", - "PDF" - ] - }, - "bookConfig": { - "description": "Book strategy-specific configuration (JSON)" - }, - "bookCount": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "description": "Total number of books in this library", - "example": 1250 - }, - "bookStrategy": { - "$ref": "#/components/schemas/BookStrategy", - "description": "Book naming strategy (filename, metadata_first, smart, series_name)" - }, "createdAt": { "type": "string", - "format": "date-time", - "description": "When the library was created", - "example": "2024-01-01T00:00:00Z" - }, - "defaultReadingDirection": { - "type": "string", - "description": "Default reading direction for books in this library (ltr, rtl, ttb or webtoon)", - "example": "ltr" - }, - "description": { - "type": [ - "string", - "null" - ], - "description": "Optional description", - "example": "My comic book collection" - }, - "excludedPatterns": { - "type": [ - "string", - "null" - ], - "description": "Excluded path patterns (newline-separated, e.g., \".DS_Store\\nThumbs.db\")", - "example": ".DS_Store\nThumbs.db" + "format": "date-time", + "description": "Account creation timestamp", + "example": "2024-01-01T00:00:00Z" + }, + "email": { + "type": "string", + "description": "User email address", + "example": "john.doe@example.com" }, "id": { "type": "string", "format": "uuid", - "description": "Library unique identifier", + "description": "Unique user identifier", "example": "550e8400-e29b-41d4-a716-446655440000" }, "isActive": { "type": "boolean", - "description": "Whether the library is active", + "description": "Whether the account is active", "example": true }, - "lastScannedAt": { + "lastLoginAt": { "type": [ "string", "null" ], "format": "date-time", - "description": "When the library was last scanned", + "description": "Timestamp of last login", "example": "2024-01-15T10:30:00Z" }, - "name": { - "type": "string", - "description": "Library name", - "example": "Comics" - }, - "numberConfig": { - "description": "Number strategy-specific configuration (JSON)" - }, - "numberStrategy": { - "$ref": "#/components/schemas/NumberStrategy", - "description": "Book number strategy (file_order, metadata, filename, smart)" - }, - "path": { - "type": "string", - "description": "Filesystem path to the library root", - "example": "/media/comics" - }, - "scanningConfig": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ScanningConfigDto", - "description": "Scanning configuration for scheduled scans" - } - ] - }, - "seriesConfig": { - "description": "Strategy-specific configuration (JSON)" - }, - "seriesCount": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "description": "Total number of series in this library", - "example": 85 + "permissions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Custom permissions that extend the role's base permissions" }, - "seriesStrategy": { - "$ref": "#/components/schemas/SeriesStrategy", - "description": "Series detection strategy (series_volume, series_volume_chapter, flat, etc.)" + "role": { + "$ref": "#/components/schemas/UserRole", + "description": "User role (reader, maintainer, admin)" }, "updatedAt": { "type": "string", "format": "date-time", - "description": "When the library was last updated", + "description": "Last account update timestamp", "example": "2024-01-15T10:30:00Z" + }, + "username": { + "type": "string", + "description": "Username for login", + "example": "johndoe" } } }, "description": "The data items for this page" }, - "links": { - "$ref": "#/components/schemas/PaginationLinks", - "description": "HATEOAS navigation links" + "links": { + "$ref": "#/components/schemas/PaginationLinks", + "description": "HATEOAS navigation links" + }, + "page": { + "type": "integer", + "format": "int64", + "description": "Current page number (1-indexed)", + "example": 1, + "minimum": 0 + }, + "pageSize": { + "type": "integer", + "format": "int64", + "description": "Number of items per page", + "example": 50, + "minimum": 0 + }, + "total": { + "type": "integer", + "format": "int64", + "description": "Total number of items across all pages", + "example": 150, + "minimum": 0 + }, + "totalPages": { + "type": "integer", + "format": "int64", + "description": "Total number of pages", + "example": 3, + "minimum": 0 + } + } + }, + "PaginationLinks": { + "type": "object", + "description": "HATEOAS navigation links for paginated responses (RFC 8288)", + "required": [ + "self", + "first", + "last" + ], + "properties": { + "first": { + "type": "string", + "description": "Link to the first page" + }, + "last": { + "type": "string", + "description": "Link to the last page" + }, + "next": { + "type": [ + "string", + "null" + ], + "description": "Link to the next page (null if on last page)" + }, + "prev": { + "type": [ + "string", + "null" + ], + "description": "Link to the previous page (null if on first page)" + }, + "self": { + "type": "string", + "description": "Link to the current page" + } + } + }, + "PatchBookMetadataRequest": { + "type": "object", + "description": "PATCH request for partial update of book metadata\n\nOnly provided fields will be updated. Absent fields are unchanged.\nExplicitly null fields will be cleared.", + "properties": { + "blackAndWhite": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the book is black and white", + "example": false + }, + "colorist": { + "type": [ + "string", + "null" + ], + "description": "Colorist(s) - comma-separated if multiple", + "example": "Richmond Lewis" + }, + "count": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Total count in series", + "example": 4 + }, + "coverArtist": { + "type": [ + "string", + "null" + ], + "description": "Cover artist(s) - comma-separated if multiple", + "example": "David Mazzucchelli" + }, + "day": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Publication day (1-31)", + "example": 1 + }, + "editor": { + "type": [ + "string", + "null" + ], + "description": "Editor(s) - comma-separated if multiple", + "example": "Dennis O'Neil" + }, + "formatDetail": { + "type": [ + "string", + "null" + ], + "description": "Format details", + "example": "Trade Paperback" + }, + "genre": { + "type": [ + "string", + "null" + ], + "description": "Genre", + "example": "Superhero" + }, + "imprint": { + "type": [ + "string", + "null" + ], + "description": "Imprint name", + "example": "DC Black Label" + }, + "inker": { + "type": [ + "string", + "null" + ], + "description": "Inker(s) - comma-separated if multiple", + "example": "David Mazzucchelli" + }, + "isbns": { + "type": [ + "string", + "null" + ], + "description": "ISBN(s) - comma-separated if multiple", + "example": "978-1401207526" + }, + "languageIso": { + "type": [ + "string", + "null" + ], + "description": "ISO language code", + "example": "en" + }, + "letterer": { + "type": [ + "string", + "null" + ], + "description": "Letterer(s) - comma-separated if multiple", + "example": "Todd Klein" + }, + "manga": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the book is manga format", + "example": false + }, + "month": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Publication month (1-12)", + "example": 2 }, - "page": { - "type": "integer", - "format": "int64", - "description": "Current page number (1-indexed)", - "example": 1, - "minimum": 0 + "penciller": { + "type": [ + "string", + "null" + ], + "description": "Penciller(s) - comma-separated if multiple", + "example": "David Mazzucchelli" }, - "pageSize": { - "type": "integer", - "format": "int64", - "description": "Number of items per page", - "example": 50, - "minimum": 0 + "publisher": { + "type": [ + "string", + "null" + ], + "description": "Publisher name", + "example": "DC Comics" }, - "total": { - "type": "integer", - "format": "int64", - "description": "Total number of items across all pages", - "example": 150, - "minimum": 0 + "summary": { + "type": [ + "string", + "null" + ], + "description": "Book summary/description", + "example": "Bruce Wayne returns to Gotham City." }, - "totalPages": { - "type": "integer", - "format": "int64", - "description": "Total number of pages", - "example": 3, - "minimum": 0 + "volume": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Volume number", + "example": 1 + }, + "web": { + "type": [ + "string", + "null" + ], + "description": "Web URL for more information", + "example": "https://dc.com/batman-year-one" + }, + "writer": { + "type": [ + "string", + "null" + ], + "description": "Writer(s) - comma-separated if multiple", + "example": "Frank Miller" + }, + "year": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Publication year", + "example": 1987 } } }, - "PaginatedResponse_SeriesDto": { + "PatchBookRequest": { "type": "object", - "description": "Generic paginated response wrapper with HATEOAS links", - "required": [ - "data", - "page", - "pageSize", - "total", - "totalPages", - "links" - ], + "description": "PATCH request for updating book core fields (title, number)\n\nOnly provided fields will be updated. Absent fields are unchanged.\nExplicitly null fields will be cleared.", "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "description": "Series data transfer object", - "required": [ - "id", - "libraryId", - "libraryName", - "title", - "bookCount", - "createdAt", - "updatedAt" - ], - "properties": { - "bookCount": { - "type": "integer", - "format": "int64", - "description": "Total number of books in this series", - "example": 4 - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the series was created", - "example": "2024-01-01T00:00:00Z" - }, - "hasCustomCover": { - "type": [ - "boolean", - "null" - ], - "description": "Whether the series has a custom cover uploaded", - "example": false - }, - "id": { - "type": "string", - "format": "uuid", - "description": "Series unique identifier", - "example": "550e8400-e29b-41d4-a716-446655440002" - }, - "libraryId": { - "type": "string", - "format": "uuid", - "description": "Library unique identifier", - "example": "550e8400-e29b-41d4-a716-446655440000" - }, - "libraryName": { - "type": "string", - "description": "Name of the library this series belongs to", - "example": "Comics" - }, - "path": { - "type": [ - "string", - "null" - ], - "description": "Filesystem path to the series directory", - "example": "/media/comics/Batman - Year One" - }, - "publisher": { - "type": [ - "string", - "null" - ], - "description": "Publisher name", - "example": "DC Comics" - }, - "selectedCoverSource": { - "type": [ - "string", - "null" - ], - "description": "Selected cover source (e.g., \"first_book\", \"custom\")", - "example": "first_book" - }, - "summary": { - "type": [ - "string", - "null" - ], - "description": "Summary/description from series_metadata", - "example": "The definitive origin story of Batman, following Bruce Wayne's first year as a vigilante." - }, - "title": { - "type": "string", - "description": "Series title from series_metadata", - "example": "Batman: Year One" - }, - "titleSort": { - "type": [ - "string", - "null" - ], - "description": "Sort title from series_metadata (for ordering)", - "example": "batman year one" - }, - "unreadCount": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "description": "Number of unread books in this series (user-specific)", - "example": 2 - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the series was last updated", - "example": "2024-01-15T10:30:00Z" - }, - "year": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Release year", - "example": 1987 - } - } - }, - "description": "The data items for this page" + "number": { + "type": [ + "number", + "null" + ], + "format": "double", + "description": "Book number (for sorting within series). Supports decimals like 1.5 for special chapters.", + "example": 1.5 + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Book title (display name)", + "example": "Chapter 1: The Beginning" + } + } + }, + "PatchSeriesMetadataRequest": { + "type": "object", + "description": "PATCH request for partial update of series metadata\n\nOnly provided fields will be updated. Absent fields are unchanged.\nExplicitly null fields will be cleared.", + "properties": { + "ageRating": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Age rating (e.g., 13, 16, 18)", + "example": 16 }, - "links": { - "$ref": "#/components/schemas/PaginationLinks", - "description": "HATEOAS navigation links" + "customMetadata": { + "type": [ + "object", + "null" + ], + "description": "Custom JSON metadata for extensions" }, - "page": { - "type": "integer", - "format": "int64", - "description": "Current page number (1-indexed)", - "example": 1, - "minimum": 0 + "imprint": { + "type": [ + "string", + "null" + ], + "description": "Imprint (sub-publisher)", + "example": "Vertigo" }, - "pageSize": { - "type": "integer", - "format": "int64", - "description": "Number of items per page", - "example": 50, - "minimum": 0 + "language": { + "type": [ + "string", + "null" + ], + "description": "Language (BCP47 format: \"en\", \"ja\", \"ko\")", + "example": "en" }, - "total": { + "publisher": { + "type": [ + "string", + "null" + ], + "description": "Publisher name", + "example": "DC Comics" + }, + "readingDirection": { + "type": [ + "string", + "null" + ], + "description": "Reading direction (ltr, rtl, ttb or webtoon)", + "example": "ltr" + }, + "status": { + "type": [ + "string", + "null" + ], + "description": "Series status (ongoing, ended, hiatus, abandoned, unknown)", + "example": "ended" + }, + "summary": { + "type": [ + "string", + "null" + ], + "description": "Series description/summary", + "example": "The definitive origin story of Batman." + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Series title/name", + "example": "Batman: Year One" + }, + "titleSort": { + "type": [ + "string", + "null" + ], + "description": "Custom sort name for ordering", + "example": "Batman Year One" + }, + "totalBookCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Expected total book count (for ongoing series)", + "example": 4 + }, + "year": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Release year", + "example": 1987 + } + } + }, + "PatchSeriesRequest": { + "type": "object", + "description": "PATCH request for updating series title\n\nOnly provided fields will be updated. Absent fields are unchanged.\nExplicitly null fields will be cleared (where applicable).", + "properties": { + "title": { + "type": [ + "string", + "null" + ], + "description": "Series title (stored in series_metadata.title)", + "example": "Batman: Year One" + } + } + }, + "PdfCacheCleanupResultDto": { + "type": "object", + "description": "Result of a PDF cache cleanup operation", + "required": [ + "files_deleted", + "bytes_reclaimed", + "bytes_reclaimed_human" + ], + "properties": { + "bytes_reclaimed": { "type": "integer", "format": "int64", - "description": "Total number of items across all pages", - "example": 150, + "description": "Bytes freed by the cleanup", + "example": 26214400, "minimum": 0 }, - "totalPages": { + "bytes_reclaimed_human": { + "type": "string", + "description": "Human-readable size reclaimed (e.g., \"25.0 MB\")", + "example": "25.0 MB" + }, + "files_deleted": { "type": "integer", "format": "int64", - "description": "Total number of pages", - "example": 3, + "description": "Number of cached page files deleted", + "example": 250, "minimum": 0 } } }, - "PaginatedResponse_SharingTagDto": { + "PdfCacheStatsDto": { "type": "object", - "description": "Generic paginated response wrapper with HATEOAS links", + "description": "Statistics about the PDF page cache", "required": [ - "data", - "page", - "pageSize", - "total", - "totalPages", - "links" + "total_files", + "total_size_bytes", + "total_size_human", + "book_count", + "cache_dir", + "cache_enabled" ], "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "description": "Sharing tag data transfer object", - "required": [ - "id", - "name", - "seriesCount", - "userCount", - "createdAt", - "updatedAt" - ], - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "description": "Creation timestamp", - "example": "2024-01-01T00:00:00Z" - }, - "description": { - "type": [ - "string", - "null" - ], - "description": "Optional description", - "example": "Content appropriate for children" - }, - "id": { - "type": "string", - "format": "uuid", - "description": "Unique sharing tag identifier", - "example": "550e8400-e29b-41d4-a716-446655440000" - }, - "name": { - "type": "string", - "description": "Display name of the sharing tag", - "example": "Kids Content" - }, - "seriesCount": { - "type": "integer", - "format": "int64", - "description": "Number of series tagged with this sharing tag", - "example": 42, - "minimum": 0 - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "Last update timestamp", - "example": "2024-01-15T10:30:00Z" - }, - "userCount": { - "type": "integer", - "format": "int64", - "description": "Number of users with grants for this sharing tag", - "example": 5, - "minimum": 0 - } - } - }, - "description": "The data items for this page" - }, - "links": { - "$ref": "#/components/schemas/PaginationLinks", - "description": "HATEOAS navigation links" - }, - "page": { + "book_count": { "type": "integer", "format": "int64", - "description": "Current page number (1-indexed)", - "example": 1, + "description": "Number of unique books with cached pages", + "example": 45, "minimum": 0 }, - "pageSize": { - "type": "integer", - "format": "int64", - "description": "Number of items per page", - "example": 50, + "cache_dir": { + "type": "string", + "description": "Path to the cache directory", + "example": "/data/cache" + }, + "cache_enabled": { + "type": "boolean", + "description": "Whether the PDF page cache is enabled", + "example": true + }, + "oldest_file_age_days": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Age of the oldest cached file in days (if any files exist)", + "example": 15, "minimum": 0 }, - "total": { + "total_files": { "type": "integer", "format": "int64", - "description": "Total number of items across all pages", - "example": 150, + "description": "Total number of cached page files", + "example": 1500, "minimum": 0 }, - "totalPages": { + "total_size_bytes": { "type": "integer", "format": "int64", - "description": "Total number of pages", - "example": 3, + "description": "Total size of cache in bytes", + "example": 157286400, "minimum": 0 + }, + "total_size_human": { + "type": "string", + "description": "Human-readable total size (e.g., \"150.0 MB\")", + "example": "150.0 MB" } } }, - "PaginatedResponse_TagDto": { + "PluginActionDto": { "type": "object", - "description": "Generic paginated response wrapper with HATEOAS links", + "description": "A plugin action available for a specific scope", "required": [ - "data", - "page", - "pageSize", - "total", - "totalPages", - "links" + "pluginId", + "pluginName", + "pluginDisplayName", + "actionType", + "label" ], "properties": { - "data": { + "actionType": { + "type": "string", + "description": "Action type (e.g., \"metadata_search\", \"metadata_get\")" + }, + "description": { + "type": [ + "string", + "null" + ], + "description": "Description of the action" + }, + "icon": { + "type": [ + "string", + "null" + ], + "description": "Icon hint for UI (optional)" + }, + "label": { + "type": "string", + "description": "Human-readable label for the action" + }, + "libraryIds": { "type": "array", "items": { - "type": "object", - "description": "Tag data transfer object", - "required": [ - "id", - "name", - "createdAt" - ], - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the tag was created", - "example": "2024-01-01T00:00:00Z" - }, - "id": { - "type": "string", - "format": "uuid", - "description": "Tag ID", - "example": "550e8400-e29b-41d4-a716-446655440020" - }, - "name": { - "type": "string", - "description": "Tag name", - "example": "Completed" - }, - "seriesCount": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "description": "Number of series with this tag", - "example": 15, - "minimum": 0 - } - } + "type": "string", + "format": "uuid" }, - "description": "The data items for this page" - }, - "links": { - "$ref": "#/components/schemas/PaginationLinks", - "description": "HATEOAS navigation links" - }, - "page": { - "type": "integer", - "format": "int64", - "description": "Current page number (1-indexed)", - "example": 1, - "minimum": 0 + "description": "Library IDs this plugin applies to (empty means all libraries)\nUsed by frontend to filter which plugins show up for each library" }, - "pageSize": { - "type": "integer", - "format": "int64", - "description": "Number of items per page", - "example": 50, - "minimum": 0 + "pluginDisplayName": { + "type": "string", + "description": "Plugin display name" }, - "total": { - "type": "integer", - "format": "int64", - "description": "Total number of items across all pages", - "example": 150, - "minimum": 0 + "pluginId": { + "type": "string", + "format": "uuid", + "description": "Plugin ID" }, - "totalPages": { - "type": "integer", - "format": "int64", - "description": "Total number of pages", - "example": 3, - "minimum": 0 + "pluginName": { + "type": "string", + "description": "Plugin name" } } }, - "PaginatedResponse_UserDto": { + "PluginActionRequest": { + "oneOf": [ + { + "type": "object", + "description": "Metadata plugin actions (search, get, match)", + "required": [ + "metadata" + ], + "properties": { + "metadata": { + "type": "object", + "description": "Metadata plugin actions (search, get, match)", + "required": [ + "action", + "content_type" + ], + "properties": { + "action": { + "$ref": "#/components/schemas/MetadataAction", + "description": "The metadata action to perform" + }, + "content_type": { + "$ref": "#/components/schemas/MetadataContentType", + "description": "Content type (series or book)" + }, + "params": { + "description": "Action-specific parameters" + } + } + } + } + }, + { + "type": "string", + "description": "Health check (works for any plugin type)", + "enum": [ + "ping" + ] + } + ], + "description": "Plugin action request - tagged by plugin type\n\nEach plugin type has its own set of valid actions.\nThis ensures type safety - you can't call a metadata action on a sync plugin." + }, + "PluginActionsResponse": { "type": "object", - "description": "Generic paginated response wrapper with HATEOAS links", + "description": "Response containing available plugin actions for a scope", "required": [ - "data", - "page", - "pageSize", - "total", - "totalPages", - "links" + "actions", + "scope" ], "properties": { - "data": { + "actions": { "type": "array", "items": { - "type": "object", - "description": "User data transfer object", - "required": [ - "id", - "username", - "email", - "role", - "permissions", - "isActive", - "createdAt", - "updatedAt" - ], - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "description": "Account creation timestamp", - "example": "2024-01-01T00:00:00Z" - }, - "email": { - "type": "string", - "description": "User email address", - "example": "john.doe@example.com" - }, - "id": { - "type": "string", - "format": "uuid", - "description": "Unique user identifier", - "example": "550e8400-e29b-41d4-a716-446655440000" - }, - "isActive": { - "type": "boolean", - "description": "Whether the account is active", - "example": true - }, - "lastLoginAt": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "Timestamp of last login", - "example": "2024-01-15T10:30:00Z" - }, - "permissions": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Custom permissions that extend the role's base permissions" - }, - "role": { - "$ref": "#/components/schemas/UserRole", - "description": "User role (reader, maintainer, admin)" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "Last account update timestamp", - "example": "2024-01-15T10:30:00Z" - }, - "username": { - "type": "string", - "description": "Username for login", - "example": "johndoe" - } - } + "$ref": "#/components/schemas/PluginActionDto" }, - "description": "The data items for this page" - }, - "links": { - "$ref": "#/components/schemas/PaginationLinks", - "description": "HATEOAS navigation links" - }, - "page": { - "type": "integer", - "format": "int64", - "description": "Current page number (1-indexed)", - "example": 1, - "minimum": 0 - }, - "pageSize": { - "type": "integer", - "format": "int64", - "description": "Number of items per page", - "example": 50, - "minimum": 0 + "description": "Available actions grouped by plugin" }, - "total": { - "type": "integer", - "format": "int64", - "description": "Total number of items across all pages", - "example": 150, - "minimum": 0 + "scope": { + "type": "string", + "description": "The scope these actions are for" + } + } + }, + "PluginCapabilitiesDto": { + "type": "object", + "description": "Plugin capabilities", + "properties": { + "metadataProvider": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Content types this plugin can provide metadata for (e.g., [\"series\", \"book\"])" }, - "totalPages": { - "type": "integer", - "format": "int64", - "description": "Total number of pages", - "example": 3, - "minimum": 0 + "userSyncProvider": { + "type": "boolean", + "description": "Can sync user reading progress" } } }, - "PaginationLinks": { + "PluginDto": { "type": "object", - "description": "HATEOAS navigation links for paginated responses (RFC 8288)", + "description": "A plugin (credentials are never exposed)", "required": [ - "self", - "first", - "last" + "id", + "name", + "displayName", + "pluginType", + "command", + "args", + "env", + "permissions", + "scopes", + "libraryIds", + "hasCredentials", + "credentialDelivery", + "config", + "enabled", + "healthStatus", + "failureCount", + "createdAt", + "updatedAt" ], "properties": { - "first": { + "args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Command arguments", + "example": [ + "/opt/codex/plugins/mangabaka/dist/index.js" + ] + }, + "command": { "type": "string", - "description": "Link to the first page" + "description": "Command to spawn the plugin", + "example": "node" }, - "last": { + "config": { + "description": "Plugin-specific configuration" + }, + "createdAt": { "type": "string", - "description": "Link to the last page" + "format": "date-time", + "description": "When the plugin was created" }, - "next": { + "credentialDelivery": { + "type": "string", + "description": "How credentials are delivered to the plugin", + "example": "env" + }, + "description": { "type": [ "string", "null" ], - "description": "Link to the next page (null if on last page)" + "description": "Description of the plugin", + "example": "Fetch manga metadata from MangaBaka (MangaUpdates)" }, - "prev": { + "disabledReason": { "type": [ "string", "null" ], - "description": "Link to the previous page (null if on first page)" + "description": "Reason the plugin was disabled" }, - "self": { + "displayName": { "type": "string", - "description": "Link to the current page" - } - } - }, - "PatchBookMetadataRequest": { - "type": "object", - "description": "PATCH request for partial update of book metadata\n\nOnly provided fields will be updated. Absent fields are unchanged.\nExplicitly null fields will be cleared.", - "properties": { - "blackAndWhite": { - "type": [ - "boolean", - "null" - ], - "description": "Whether the book is black and white", - "example": false + "description": "Human-readable display name", + "example": "MangaBaka" }, - "colorist": { - "type": [ - "string", - "null" - ], - "description": "Colorist(s) - comma-separated if multiple", - "example": "Richmond Lewis" + "enabled": { + "type": "boolean", + "description": "Whether the plugin is enabled", + "example": true }, - "count": { - "type": [ - "integer", - "null" - ], + "env": { + "description": "Additional environment variables (non-sensitive only)" + }, + "failureCount": { + "type": "integer", "format": "int32", - "description": "Total count in series", - "example": 4 + "description": "Number of consecutive failures", + "example": 0 }, - "coverArtist": { - "type": [ - "string", - "null" - ], - "description": "Cover artist(s) - comma-separated if multiple", - "example": "David Mazzucchelli" + "hasCredentials": { + "type": "boolean", + "description": "Whether credentials have been set (actual credentials are never returned)", + "example": true }, - "day": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Publication day (1-31)", - "example": 1 + "healthStatus": { + "type": "string", + "description": "Health status: unknown, healthy, degraded, unhealthy, disabled", + "example": "healthy" }, - "editor": { + "id": { + "type": "string", + "format": "uuid", + "description": "Plugin ID", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "lastFailureAt": { "type": [ "string", "null" ], - "description": "Editor(s) - comma-separated if multiple", - "example": "Dennis O'Neil" + "format": "date-time", + "description": "When the last failure occurred" }, - "formatDetail": { + "lastSuccessAt": { "type": [ "string", "null" ], - "description": "Format details", - "example": "Trade Paperback" + "format": "date-time", + "description": "When the last successful operation occurred" }, - "genre": { + "libraryIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Library IDs this plugin applies to (empty = all libraries)", + "example": [] + }, + "manifest": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PluginManifestDto", + "description": "Cached manifest from plugin (if available)" + } + ] + }, + "name": { + "type": "string", + "description": "Unique identifier (e.g., \"mangabaka\")", + "example": "mangabaka" + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "RBAC permissions for metadata writes", + "example": [ + "metadata:write:summary", + "metadata:write:genres" + ] + }, + "pluginType": { + "type": "string", + "description": "Plugin type: \"system\" (admin-configured) or \"user\" (per-user instances)", + "example": "system" + }, + "rateLimitRequestsPerMinute": { "type": [ - "string", + "integer", "null" ], - "description": "Genre", - "example": "Superhero" + "format": "int32", + "description": "Rate limit in requests per minute (None = no limit)", + "example": 60 }, - "imprint": { + "scopes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Scopes where plugin can be invoked", + "example": [ + "series:detail", + "series:bulk" + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the plugin was last updated" + }, + "workingDirectory": { "type": [ "string", "null" ], - "description": "Imprint name", - "example": "DC Black Label" + "description": "Working directory for the plugin process" + } + } + }, + "PluginFailureDto": { + "type": "object", + "description": "A single plugin failure event", + "required": [ + "id", + "errorMessage", + "occurredAt" + ], + "properties": { + "context": { + "description": "Additional context (parameters, stack trace, etc.)" }, - "inker": { + "errorCode": { "type": [ "string", "null" ], - "description": "Inker(s) - comma-separated if multiple", - "example": "David Mazzucchelli" + "description": "Error code for categorization", + "example": "TIMEOUT" }, - "isbns": { + "errorMessage": { + "type": "string", + "description": "Human-readable error message", + "example": "Connection timeout after 30s" + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Failure ID" + }, + "method": { "type": [ "string", "null" ], - "description": "ISBN(s) - comma-separated if multiple", - "example": "978-1401207526" + "description": "Which method failed", + "example": "metadata/search" }, - "languageIso": { + "occurredAt": { + "type": "string", + "format": "date-time", + "description": "When the failure occurred" + }, + "requestSummary": { "type": [ "string", "null" ], - "description": "ISO language code", - "example": "en" + "description": "Sanitized summary of request parameters (sensitive fields redacted)", + "example": "query: \"One Piece\", limit: 10" + } + } + }, + "PluginFailuresResponse": { + "type": "object", + "description": "Response containing plugin failure history", + "required": [ + "failures", + "total", + "windowFailures", + "windowSeconds", + "threshold" + ], + "properties": { + "failures": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PluginFailureDto" + }, + "description": "List of failure events" }, - "letterer": { + "threshold": { + "type": "integer", + "format": "int32", + "description": "Threshold for auto-disable", + "example": 3, + "minimum": 0 + }, + "total": { + "type": "integer", + "format": "int64", + "description": "Total number of failures (for pagination)", + "minimum": 0 + }, + "windowFailures": { + "type": "integer", + "format": "int64", + "description": "Number of failures within the current time window", + "minimum": 0 + }, + "windowSeconds": { + "type": "integer", + "format": "int64", + "description": "Time window size in seconds", + "example": 3600 + } + } + }, + "PluginHealthDto": { + "type": "object", + "description": "Plugin health information", + "required": [ + "pluginId", + "name", + "healthStatus", + "enabled", + "failureCount" + ], + "properties": { + "disabledReason": { "type": [ "string", "null" ], - "description": "Letterer(s) - comma-separated if multiple", - "example": "Todd Klein" + "description": "Reason the plugin was disabled" }, - "manga": { - "type": [ - "boolean", - "null" - ], - "description": "Whether the book is manga format", - "example": false + "enabled": { + "type": "boolean", + "description": "Whether the plugin is enabled" }, - "month": { - "type": [ - "integer", - "null" - ], + "failureCount": { + "type": "integer", "format": "int32", - "description": "Publication month (1-12)", - "example": 2 + "description": "Number of consecutive failures" }, - "penciller": { + "healthStatus": { + "type": "string", + "description": "Current health status" + }, + "lastFailureAt": { "type": [ "string", "null" ], - "description": "Penciller(s) - comma-separated if multiple", - "example": "David Mazzucchelli" + "format": "date-time", + "description": "When the last failure occurred" }, - "publisher": { + "lastSuccessAt": { "type": [ "string", "null" ], - "description": "Publisher name", - "example": "DC Comics" + "format": "date-time", + "description": "When the last successful operation occurred" }, - "summary": { + "name": { + "type": "string", + "description": "Plugin name" + }, + "pluginId": { + "type": "string", + "format": "uuid", + "description": "Plugin ID" + } + } + }, + "PluginHealthResponse": { + "type": "object", + "description": "Response containing plugin health history/summary", + "required": [ + "health" + ], + "properties": { + "health": { + "$ref": "#/components/schemas/PluginHealthDto", + "description": "Plugin health information" + } + } + }, + "PluginManifestDto": { + "type": "object", + "description": "Plugin manifest from the plugin itself", + "required": [ + "name", + "displayName", + "version", + "protocolVersion", + "capabilities", + "contentTypes" + ], + "properties": { + "author": { "type": [ "string", "null" ], - "description": "Book summary/description", - "example": "Bruce Wayne returns to Gotham City." + "description": "Author" }, - "volume": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Volume number", - "example": 1 + "capabilities": { + "$ref": "#/components/schemas/PluginCapabilitiesDto", + "description": "Plugin capabilities" }, - "web": { + "contentTypes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Supported content types" + }, + "description": { "type": [ "string", "null" ], - "description": "Web URL for more information", - "example": "https://dc.com/batman-year-one" + "description": "Description" }, - "writer": { + "displayName": { + "type": "string", + "description": "Display name for UI" + }, + "homepage": { "type": [ "string", "null" ], - "description": "Writer(s) - comma-separated if multiple", - "example": "Frank Miller" + "description": "Homepage URL" }, - "year": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Publication year", - "example": 1987 + "name": { + "type": "string", + "description": "Unique identifier" + }, + "protocolVersion": { + "type": "string", + "description": "Protocol version" + }, + "requiredCredentials": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CredentialFieldDto" + }, + "description": "Required credentials" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Supported scopes" + }, + "version": { + "type": "string", + "description": "Semantic version" } } }, - "PatchSeriesMetadataRequest": { + "PluginMethodMetricsDto": { "type": "object", - "description": "PATCH request for partial update of series metadata\n\nOnly provided fields will be updated. Absent fields are unchanged.\nExplicitly null fields will be cleared.", + "description": "Metrics breakdown by method for a plugin", + "required": [ + "method", + "requests_total", + "requests_success", + "requests_failed", + "avg_duration_ms" + ], "properties": { - "ageRating": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Age rating (e.g., 13, 16, 18)", - "example": 16 + "avg_duration_ms": { + "type": "number", + "format": "double", + "description": "Average duration in milliseconds", + "example": 180.5 }, - "customMetadata": { + "method": { + "type": "string", + "description": "Method name", + "example": "search" + }, + "requests_failed": { + "type": "integer", + "format": "int64", + "description": "Failed requests", + "example": 5, + "minimum": 0 + }, + "requests_success": { + "type": "integer", + "format": "int64", + "description": "Successful requests", + "example": 195, + "minimum": 0 + }, + "requests_total": { + "type": "integer", + "format": "int64", + "description": "Total requests for this method", + "example": 200, + "minimum": 0 + } + } + }, + "PluginMetricsDto": { + "type": "object", + "description": "Metrics for a single plugin", + "required": [ + "plugin_id", + "plugin_name", + "requests_total", + "requests_success", + "requests_failed", + "avg_duration_ms", + "rate_limit_rejections", + "error_rate_pct", + "health_status" + ], + "properties": { + "avg_duration_ms": { + "type": "number", + "format": "double", + "description": "Average request duration in milliseconds", + "example": 250.5 + }, + "by_method": { "type": [ "object", "null" ], - "description": "Custom JSON metadata for extensions" + "description": "Per-method breakdown", + "additionalProperties": { + "$ref": "#/components/schemas/PluginMethodMetricsDto" + }, + "propertyNames": { + "type": "string" + } }, - "imprint": { - "type": [ - "string", - "null" - ], - "description": "Imprint (sub-publisher)", - "example": "Vertigo" + "error_rate_pct": { + "type": "number", + "format": "double", + "description": "Error rate as percentage", + "example": 4.0 }, - "language": { + "failure_counts": { "type": [ - "string", + "object", "null" ], - "description": "Language (BCP47 format: \"en\", \"ja\", \"ko\")", - "example": "en" + "description": "Failure counts by error code", + "additionalProperties": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "propertyNames": { + "type": "string" + } }, - "publisher": { + "health_status": { + "type": "string", + "description": "Current health status", + "example": "healthy" + }, + "last_failure": { "type": [ "string", "null" ], - "description": "Publisher name", - "example": "DC Comics" + "format": "date-time", + "description": "Last failure timestamp" }, - "readingDirection": { + "last_success": { "type": [ "string", "null" ], - "description": "Reading direction (ltr, rtl, ttb or webtoon)", - "example": "ltr" + "format": "date-time", + "description": "Last successful request timestamp" }, - "status": { + "plugin_id": { + "type": "string", + "format": "uuid", + "description": "Plugin ID", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "plugin_name": { + "type": "string", + "description": "Plugin name", + "example": "AniList Provider" + }, + "rate_limit_rejections": { + "type": "integer", + "format": "int64", + "description": "Number of rate limit rejections", + "example": 2, + "minimum": 0 + }, + "requests_failed": { + "type": "integer", + "format": "int64", + "description": "Failed requests", + "example": 20, + "minimum": 0 + }, + "requests_success": { + "type": "integer", + "format": "int64", + "description": "Successful requests", + "example": 480, + "minimum": 0 + }, + "requests_total": { + "type": "integer", + "format": "int64", + "description": "Total requests made", + "example": 500, + "minimum": 0 + } + } + }, + "PluginMetricsResponse": { + "type": "object", + "description": "Plugin metrics response - current performance statistics for all plugins", + "required": [ + "updated_at", + "summary", + "plugins" + ], + "properties": { + "plugins": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PluginMetricsDto" + }, + "description": "Per-plugin breakdown" + }, + "summary": { + "$ref": "#/components/schemas/PluginMetricsSummaryDto", + "description": "Overall summary statistics" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "When the metrics were last updated", + "example": "2026-01-30T12:00:00Z" + } + } + }, + "PluginMetricsSummaryDto": { + "type": "object", + "description": "Summary metrics across all plugins", + "required": [ + "total_plugins", + "healthy_plugins", + "degraded_plugins", + "unhealthy_plugins", + "total_requests", + "total_success", + "total_failed", + "total_rate_limit_rejections" + ], + "properties": { + "degraded_plugins": { + "type": "integer", + "format": "int64", + "description": "Number of degraded plugins", + "example": 1, + "minimum": 0 + }, + "healthy_plugins": { + "type": "integer", + "format": "int64", + "description": "Number of healthy plugins", + "example": 2, + "minimum": 0 + }, + "total_failed": { + "type": "integer", + "format": "int64", + "description": "Total failed requests", + "example": 100, + "minimum": 0 + }, + "total_plugins": { + "type": "integer", + "format": "int64", + "description": "Total number of registered plugins", + "example": 3, + "minimum": 0 + }, + "total_rate_limit_rejections": { + "type": "integer", + "format": "int64", + "description": "Total rate limit rejections", + "example": 5, + "minimum": 0 + }, + "total_requests": { + "type": "integer", + "format": "int64", + "description": "Total requests made across all plugins", + "example": 1500, + "minimum": 0 + }, + "total_success": { + "type": "integer", + "format": "int64", + "description": "Total successful requests", + "example": 1400, + "minimum": 0 + }, + "unhealthy_plugins": { + "type": "integer", + "format": "int64", + "description": "Number of unhealthy plugins", + "example": 0, + "minimum": 0 + } + } + }, + "PluginSearchResponse": { + "type": "object", + "description": "Response containing search results from a plugin", + "required": [ + "results", + "pluginId", + "pluginName" + ], + "properties": { + "nextCursor": { "type": [ "string", "null" ], - "description": "Series status (ongoing, ended, hiatus, abandoned, unknown)", - "example": "ended" + "description": "Cursor for next page (if available)" + }, + "pluginId": { + "type": "string", + "format": "uuid", + "description": "Plugin that provided the results" + }, + "pluginName": { + "type": "string", + "description": "Plugin name" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PluginSearchResultDto" + }, + "description": "Search results" + } + } + }, + "PluginSearchResultDto": { + "type": "object", + "description": "Search result from a plugin", + "required": [ + "externalId", + "title" + ], + "properties": { + "alternateTitles": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Alternative titles" }, - "summary": { + "coverUrl": { "type": [ "string", "null" ], - "description": "Series description/summary", - "example": "The definitive origin story of Batman." + "description": "Cover image URL" }, - "title": { - "type": [ - "string", - "null" - ], - "description": "Series title/name", - "example": "Batman: Year One" + "externalId": { + "type": "string", + "description": "External ID from the provider" }, - "titleSort": { - "type": [ - "string", - "null" - ], - "description": "Custom sort name for ordering", - "example": "Batman Year One" + "preview": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SearchResultPreviewDto", + "description": "Preview data for search results" + } + ] }, - "totalBookCount": { + "relevanceScore": { "type": [ - "integer", + "number", "null" ], - "format": "int32", - "description": "Expected total book count (for ongoing series)", - "example": 4 + "format": "double", + "description": "Relevance score (0.0-1.0). Optional - if not provided, result order indicates relevance." + }, + "title": { + "type": "string", + "description": "Primary title" }, "year": { "type": [ @@ -18968,98 +22334,119 @@ "null" ], "format": "int32", - "description": "Release year", - "example": 1987 + "description": "Year of publication" } } }, - "PdfCacheCleanupResultDto": { + "PluginStatusResponse": { "type": "object", - "description": "Result of a PDF cache cleanup operation", + "description": "Response after enabling or disabling a plugin", "required": [ - "files_deleted", - "bytes_reclaimed", - "bytes_reclaimed_human" + "plugin", + "message" ], "properties": { - "bytes_reclaimed": { - "type": "integer", + "healthCheckError": { + "type": [ + "string", + "null" + ], + "description": "Health check error message (None if passed or not performed)" + }, + "healthCheckLatencyMs": { + "type": [ + "integer", + "null" + ], "format": "int64", - "description": "Bytes freed by the cleanup", - "example": 26214400, + "description": "Health check latency in milliseconds (None if not performed)", + "example": 150, "minimum": 0 }, - "bytes_reclaimed_human": { + "healthCheckPassed": { + "type": [ + "boolean", + "null" + ], + "description": "Health check passed (None if not performed)", + "example": true + }, + "healthCheckPerformed": { + "type": "boolean", + "description": "Whether a health check was performed", + "example": true + }, + "message": { "type": "string", - "description": "Human-readable size reclaimed (e.g., \"25.0 MB\")", - "example": "25.0 MB" + "description": "Status change message", + "example": "Plugin enabled successfully" }, - "files_deleted": { - "type": "integer", - "format": "int64", - "description": "Number of cached page files deleted", - "example": 250, - "minimum": 0 + "plugin": { + "$ref": "#/components/schemas/PluginDto", + "description": "The updated plugin" } } }, - "PdfCacheStatsDto": { + "PluginTestResult": { "type": "object", - "description": "Statistics about the PDF page cache", + "description": "Response from testing a plugin connection", "required": [ - "total_files", - "total_size_bytes", - "total_size_human", - "book_count", - "cache_dir", - "cache_enabled" + "success", + "message" ], "properties": { - "book_count": { - "type": "integer", + "latencyMs": { + "type": [ + "integer", + "null" + ], "format": "int64", - "description": "Number of unique books with cached pages", - "example": 45, + "description": "Response latency in milliseconds", + "example": 150, "minimum": 0 }, - "cache_dir": { + "manifest": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PluginManifestDto", + "description": "Plugin manifest (if connection succeeded)" + } + ] + }, + "message": { "type": "string", - "description": "Path to the cache directory", - "example": "/data/cache" + "description": "Test result message", + "example": "Successfully connected to plugin" }, - "cache_enabled": { + "success": { "type": "boolean", - "description": "Whether the PDF page cache is enabled", + "description": "Whether the test was successful", "example": true + } + } + }, + "PluginsListResponse": { + "type": "object", + "description": "Response containing a list of plugins", + "required": [ + "plugins", + "total" + ], + "properties": { + "plugins": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PluginDto" + }, + "description": "List of plugins" }, - "oldest_file_age_days": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Age of the oldest cached file in days (if any files exist)", - "example": 15, - "minimum": 0 - }, - "total_files": { - "type": "integer", - "format": "int64", - "description": "Total number of cached page files", - "example": 1500, - "minimum": 0 - }, - "total_size_bytes": { + "total": { "type": "integer", - "format": "int64", - "description": "Total size of cache in bytes", - "example": 157286400, + "description": "Total count", "minimum": 0 - }, - "total_size_human": { - "type": "string", - "description": "Human-readable total size (e.g., \"150.0 MB\")", - "example": "150.0 MB" } } }, @@ -19113,6 +22500,44 @@ } } }, + "PreviewSummary": { + "type": "object", + "description": "Summary of preview results", + "required": [ + "willApply", + "locked", + "noPermission", + "unchanged", + "notProvided" + ], + "properties": { + "locked": { + "type": "integer", + "description": "Number of fields that are locked", + "minimum": 0 + }, + "noPermission": { + "type": "integer", + "description": "Number of fields with no permission", + "minimum": 0 + }, + "notProvided": { + "type": "integer", + "description": "Number of fields not provided", + "minimum": 0 + }, + "unchanged": { + "type": "integer", + "description": "Number of fields that are unchanged", + "minimum": 0 + }, + "willApply": { + "type": "integer", + "description": "Number of fields that will be applied", + "minimum": 0 + } + } + }, "PublicSettingDto": { "type": "object", "description": "Public setting DTO (for non-admin users)\n\nA simplified setting DTO that only includes the key and value,\nused for public display settings accessible to all authenticated users.", @@ -20064,6 +23489,41 @@ } } }, + "SearchResultPreviewDto": { + "type": "object", + "description": "Preview data for search results", + "properties": { + "description": { + "type": [ + "string", + "null" + ], + "description": "Short description" + }, + "genres": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Genres" + }, + "rating": { + "type": [ + "number", + "null" + ], + "format": "double", + "description": "Rating" + }, + "status": { + "type": [ + "string", + "null" + ], + "description": "Status string" + } + } + }, "SearchSeriesRequest": { "type": "object", "description": "Search series request", @@ -20825,6 +24285,34 @@ "custom" ] }, + "SeriesUpdateResponse": { + "type": "object", + "description": "Response for series update", + "required": [ + "id", + "title", + "updatedAt" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Series ID", + "example": "550e8400-e29b-41d4-a716-446655440002" + }, + "title": { + "type": "string", + "description": "Updated title", + "example": "Batman: Year One" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Last update timestamp", + "example": "2024-01-15T10:30:00Z" + } + } + }, "SetPreferenceRequest": { "type": "object", "description": "Request to set a single preference value", @@ -21268,6 +24756,24 @@ } } }, + "SkippedField": { + "type": "object", + "description": "A field that was skipped during apply", + "required": [ + "field", + "reason" + ], + "properties": { + "field": { + "type": "string", + "description": "Field name" + }, + "reason": { + "type": "string", + "description": "Reason for skipping" + } + } + }, "SmartBookConfig": { "type": "object", "description": "Configuration for smart book naming strategy", @@ -22024,23 +25530,48 @@ }, { "type": "object", - "description": "Generate thumbnail for a series (from first book's cover)", + "description": "Generate thumbnail for a series (from first book's cover)", + "required": [ + "series_id", + "type" + ], + "properties": { + "force": { + "type": "boolean" + }, + "series_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "generate_series_thumbnail" + ] + } + } + }, + { + "type": "object", + "description": "Generate thumbnails for series in a scope (library or all)\nThis is a fan-out task that enqueues individual GenerateSeriesThumbnail tasks", "required": [ - "series_id", "type" ], "properties": { "force": { "type": "boolean" }, - "series_id": { - "type": "string", + "library_id": { + "type": [ + "string", + "null" + ], "format": "uuid" }, "type": { "type": "string", "enum": [ - "generate_series_thumbnail" + "generate_series_thumbnails" ] } } @@ -22144,6 +25675,38 @@ ] } } + }, + { + "type": "object", + "description": "Auto-match metadata for a series using a plugin", + "required": [ + "series_id", + "plugin_id", + "type" + ], + "properties": { + "plugin_id": { + "type": "string", + "format": "uuid" + }, + "series_id": { + "type": "string", + "format": "uuid" + }, + "source_scope": { + "type": [ + "string", + "null" + ], + "description": "Source scope that triggered this task (for tracking)" + }, + "type": { + "type": "string", + "enum": [ + "plugin_auto_match" + ] + } + } } ], "description": "Task types supported by the distributed task queue" @@ -22530,6 +26093,167 @@ } } }, + "UpdateBookMetadataLocksRequest": { + "type": "object", + "description": "Request to update book metadata locks\n\nAll fields are optional. Only provided fields will be updated.", + "properties": { + "blackAndWhiteLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock black_and_white" + }, + "coloristLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock colorist" + }, + "countLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock count" + }, + "coverArtistLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock cover artist" + }, + "dayLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock day" + }, + "editorLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock editor" + }, + "formatDetailLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock format_detail" + }, + "genreLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock genre" + }, + "imprintLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock imprint" + }, + "inkerLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock inker" + }, + "isbnsLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock isbns" + }, + "languageIsoLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock language_iso" + }, + "lettererLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock letterer" + }, + "mangaLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock manga" + }, + "monthLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock month" + }, + "pencillerLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock penciller" + }, + "publisherLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock publisher" + }, + "summaryLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock summary", + "example": true + }, + "volumeLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock volume" + }, + "webLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock web URL" + }, + "writerLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock writer" + }, + "yearLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock year" + } + } + }, "UpdateLibraryRequest": { "type": "object", "description": "Update library request\n\nNote: series_strategy and series_config are immutable after library creation.\nbook_strategy, book_config, number_strategy, and number_config can be updated.", @@ -22755,6 +26479,114 @@ } } }, + "UpdatePluginRequest": { + "type": "object", + "description": "Request to update a plugin", + "properties": { + "args": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Updated command arguments" + }, + "command": { + "type": [ + "string", + "null" + ], + "description": "Updated command" + }, + "config": { + "description": "Updated configuration" + }, + "credentialDelivery": { + "type": [ + "string", + "null" + ], + "description": "Updated credential delivery method" + }, + "credentials": { + "description": "Updated credentials (set to null to clear)" + }, + "description": { + "type": [ + "string", + "null" + ], + "description": "Updated description" + }, + "displayName": { + "type": [ + "string", + "null" + ], + "description": "Updated display name", + "example": "MangaBaka v2" + }, + "env": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/EnvVarDto" + }, + "description": "Updated environment variables" + }, + "libraryIds": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Updated library IDs (empty = all libraries)" + }, + "permissions": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Updated permissions" + }, + "rateLimitRequestsPerMinute": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Updated rate limit in requests per minute (Some(None) = remove limit)", + "example": 60 + }, + "scopes": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Updated scopes" + }, + "workingDirectory": { + "type": [ + "string", + "null" + ], + "description": "Updated working directory" + } + } + }, "UpdateProgressRequest": { "type": "object", "description": "Request to update reading progress for a book", @@ -23464,6 +27296,14 @@ "name": "Settings", "description": "Runtime configuration settings (admin only)" }, + { + "name": "Plugins", + "description": "Admin-managed external plugin processes" + }, + { + "name": "Plugin Actions", + "description": "Plugin action discovery and execution for metadata fetching" + }, { "name": "Metrics", "description": "Application metrics and statistics" @@ -23550,6 +27390,8 @@ "tags": [ "Admin", "Settings", + "Plugins", + "Plugin Actions", "Metrics", "Filesystem", "Duplicates", diff --git a/web/src/App.tsx b/web/src/App.tsx index 79a4eae2..41d9cdeb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -28,6 +28,7 @@ import { DuplicatesSettings, MetricsSettings, PdfCacheSettings, + PluginsSettings, ProfileSettings, ServerSettings, SharingTagsSettings, @@ -260,6 +261,17 @@ function App() { } /> + + + + + + } + /> + ; export interface BookFilters { @@ -425,4 +401,50 @@ export const booksApi = { ); return response.data; }, + + // ==================== Bulk Operations API ==================== + + /** + * Mark multiple books as read in bulk + * @param bookIds - Array of book IDs to mark as read + */ + bulkMarkAsRead: async ( + bookIds: string[], + ): Promise<{ count: number; message: string }> => { + const response = await api.post<{ count: number; message: string }>( + "/books/bulk/read", + { bookIds }, + ); + return response.data; + }, + + /** + * Mark multiple books as unread in bulk + * @param bookIds - Array of book IDs to mark as unread + */ + bulkMarkAsUnread: async ( + bookIds: string[], + ): Promise<{ count: number; message: string }> => { + const response = await api.post<{ count: number; message: string }>( + "/books/bulk/unread", + { bookIds }, + ); + return response.data; + }, + + /** + * Queue analysis for multiple books in bulk + * @param bookIds - Array of book IDs to analyze + * @param force - Whether to force re-analysis of already analyzed books (default: true) + */ + bulkAnalyze: async ( + bookIds: string[], + force = true, + ): Promise<{ tasksEnqueued: number; message: string }> => { + const response = await api.post<{ tasksEnqueued: number; message: string }>( + "/books/bulk/analyze", + { bookIds, force }, + ); + return response.data; + }, }; diff --git a/web/src/api/libraries.test.ts b/web/src/api/libraries.test.ts new file mode 100644 index 00000000..b5fa88b0 --- /dev/null +++ b/web/src/api/libraries.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { api } from "./client"; +import { librariesApi } from "./libraries"; + +// Mock the api client +vi.mock("./client", () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, +})); + +describe("librariesApi", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("getAll", () => { + it("should fetch all libraries", async () => { + const mockLibraries = [ + { id: "lib-1", name: "Library 1" }, + { id: "lib-2", name: "Library 2" }, + ]; + + vi.mocked(api.get).mockResolvedValueOnce({ + data: { data: mockLibraries }, + }); + + const result = await librariesApi.getAll(); + + expect(api.get).toHaveBeenCalledWith("/libraries"); + expect(result).toEqual(mockLibraries); + }); + }); + + describe("getById", () => { + it("should fetch a library by ID", async () => { + const mockLibrary = { id: "lib-123", name: "Test Library" }; + + vi.mocked(api.get).mockResolvedValueOnce({ data: mockLibrary }); + + const result = await librariesApi.getById("lib-123"); + + expect(api.get).toHaveBeenCalledWith("/libraries/lib-123"); + expect(result).toEqual(mockLibrary); + }); + }); + + describe("scan", () => { + it("should trigger a normal scan by default", async () => { + vi.mocked(api.post).mockResolvedValueOnce({}); + + await librariesApi.scan("lib-123"); + + expect(api.post).toHaveBeenCalledWith( + "/libraries/lib-123/scan?mode=normal", + ); + }); + + it("should trigger a deep scan when specified", async () => { + vi.mocked(api.post).mockResolvedValueOnce({}); + + await librariesApi.scan("lib-123", "deep"); + + expect(api.post).toHaveBeenCalledWith( + "/libraries/lib-123/scan?mode=deep", + ); + }); + }); + + describe("purgeDeleted", () => { + it("should purge deleted books from a library", async () => { + vi.mocked(api.delete).mockResolvedValueOnce({ data: 5 }); + + const result = await librariesApi.purgeDeleted("lib-123"); + + expect(api.delete).toHaveBeenCalledWith( + "/libraries/lib-123/purge-deleted", + ); + expect(result).toBe(5); + }); + }); + + describe("generateMissingThumbnails", () => { + it("should generate missing thumbnails for a library", async () => { + const mockResponse = { task_id: "task-456" }; + + vi.mocked(api.post).mockResolvedValueOnce({ data: mockResponse }); + + const result = await librariesApi.generateMissingThumbnails("lib-123"); + + expect(api.post).toHaveBeenCalledWith( + "/libraries/lib-123/books/thumbnails/generate", + { force: false }, + ); + expect(result).toEqual(mockResponse); + }); + }); + + describe("regenerateAllThumbnails", () => { + it("should regenerate all thumbnails for a library with force flag", async () => { + const mockResponse = { task_id: "task-789" }; + + vi.mocked(api.post).mockResolvedValueOnce({ data: mockResponse }); + + const result = await librariesApi.regenerateAllThumbnails("lib-123"); + + expect(api.post).toHaveBeenCalledWith( + "/libraries/lib-123/books/thumbnails/generate", + { force: true }, + ); + expect(result).toEqual(mockResponse); + }); + }); + + describe("generateMissingSeriesThumbnails", () => { + it("should generate missing series thumbnails for a library", async () => { + const mockResponse = { task_id: "task-101" }; + + vi.mocked(api.post).mockResolvedValueOnce({ data: mockResponse }); + + const result = + await librariesApi.generateMissingSeriesThumbnails("lib-123"); + + expect(api.post).toHaveBeenCalledWith( + "/libraries/lib-123/series/thumbnails/generate", + { force: false }, + ); + expect(result).toEqual(mockResponse); + }); + }); + + describe("regenerateAllSeriesThumbnails", () => { + it("should regenerate all series thumbnails for a library with force flag", async () => { + const mockResponse = { task_id: "task-202" }; + + vi.mocked(api.post).mockResolvedValueOnce({ data: mockResponse }); + + const result = + await librariesApi.regenerateAllSeriesThumbnails("lib-123"); + + expect(api.post).toHaveBeenCalledWith( + "/libraries/lib-123/series/thumbnails/generate", + { force: true }, + ); + expect(result).toEqual(mockResponse); + }); + }); +}); diff --git a/web/src/api/libraries.ts b/web/src/api/libraries.ts index 3f5b9ff8..2ad4758a 100644 --- a/web/src/api/libraries.ts +++ b/web/src/api/libraries.ts @@ -70,4 +70,46 @@ export const librariesApi = { ); return response.data; }, + + // Generate missing thumbnails for all books in a library + generateMissingThumbnails: async ( + id: string, + ): Promise<{ task_id: string }> => { + const response = await api.post<{ task_id: string }>( + `/libraries/${id}/books/thumbnails/generate`, + { force: false }, + ); + return response.data; + }, + + // Regenerate all thumbnails for all books in a library (force) + regenerateAllThumbnails: async (id: string): Promise<{ task_id: string }> => { + const response = await api.post<{ task_id: string }>( + `/libraries/${id}/books/thumbnails/generate`, + { force: true }, + ); + return response.data; + }, + + // Generate missing series thumbnails for all series in a library + generateMissingSeriesThumbnails: async ( + id: string, + ): Promise<{ task_id: string }> => { + const response = await api.post<{ task_id: string }>( + `/libraries/${id}/series/thumbnails/generate`, + { force: false }, + ); + return response.data; + }, + + // Regenerate all series thumbnails for all series in a library (force) + regenerateAllSeriesThumbnails: async ( + id: string, + ): Promise<{ task_id: string }> => { + const response = await api.post<{ task_id: string }>( + `/libraries/${id}/series/thumbnails/generate`, + { force: true }, + ); + return response.data; + }, }; diff --git a/web/src/api/metrics.ts b/web/src/api/metrics.ts index a54f55fb..d44a9503 100644 --- a/web/src/api/metrics.ts +++ b/web/src/api/metrics.ts @@ -17,6 +17,15 @@ export type TaskMetricsDataPointDto = export type MetricsCleanupResponse = components["schemas"]["MetricsCleanupResponse"]; +// Plugin metrics types +export type PluginMetricsResponse = + components["schemas"]["PluginMetricsResponse"]; +export type PluginMetricsSummaryDto = + components["schemas"]["PluginMetricsSummaryDto"]; +export type PluginMetricsDto = components["schemas"]["PluginMetricsDto"]; +export type PluginMethodMetricsDto = + components["schemas"]["PluginMethodMetricsDto"]; + export const metricsApi = { /** * Get inventory metrics (libraries, books, series counts) @@ -64,4 +73,12 @@ export const metricsApi = { ); return response.data; }, + + /** + * Get plugin metrics (performance statistics for all plugins) + */ + getPluginMetrics: async (): Promise => { + const response = await api.get("/metrics/plugins"); + return response.data; + }, }; diff --git a/web/src/api/pdfCache.ts b/web/src/api/pdfCache.ts index ed9687fd..14d1a0d3 100644 --- a/web/src/api/pdfCache.ts +++ b/web/src/api/pdfCache.ts @@ -1,48 +1,12 @@ +import type { components } from "@/types/api.generated"; import { api } from "./client"; -/** - * Statistics about the PDF page cache - */ -export interface PdfCacheStatsDto { - /** Total number of cached page files */ - total_files: number; - /** Total size of cache in bytes */ - total_size_bytes: number; - /** Human-readable total size (e.g., "150.0 MB") */ - total_size_human: string; - /** Number of unique books with cached pages */ - book_count: number; - /** Age of the oldest cached file in days (if any files exist) */ - oldest_file_age_days?: number; - /** Path to the cache directory */ - cache_dir: string; - /** Whether the PDF page cache is enabled */ - cache_enabled: boolean; -} - -/** - * Result of a PDF cache cleanup operation - */ -export interface PdfCacheCleanupResultDto { - /** Number of cached page files deleted */ - files_deleted: number; - /** Bytes freed by the cleanup */ - bytes_reclaimed: number; - /** Human-readable size reclaimed (e.g., "25.0 MB") */ - bytes_reclaimed_human: string; -} - -/** - * Response when triggering a PDF cache cleanup task - */ -export interface TriggerPdfCacheCleanupResponse { - /** ID of the queued cleanup task */ - task_id: string; - /** Message describing the action taken */ - message: string; - /** Max age setting being used for cleanup (in days) */ - max_age_days: number; -} +// Re-export generated types for convenience +export type PdfCacheStatsDto = components["schemas"]["PdfCacheStatsDto"]; +export type PdfCacheCleanupResultDto = + components["schemas"]["PdfCacheCleanupResultDto"]; +export type TriggerPdfCacheCleanupResponse = + components["schemas"]["TriggerPdfCacheCleanupResponse"]; export const pdfCacheApi = { /** diff --git a/web/src/api/plugins.ts b/web/src/api/plugins.ts new file mode 100644 index 00000000..e2e321e9 --- /dev/null +++ b/web/src/api/plugins.ts @@ -0,0 +1,460 @@ +import type { components } from "@/types/api.generated"; +import { api } from "./client"; + +// Re-export generated types for convenience +export type PluginDto = components["schemas"]["PluginDto"]; +export type PluginsListResponse = components["schemas"]["PluginsListResponse"]; +export type CreatePluginRequest = components["schemas"]["CreatePluginRequest"]; +export type UpdatePluginRequest = components["schemas"]["UpdatePluginRequest"]; +export type PluginTestResult = components["schemas"]["PluginTestResult"]; +export type PluginStatusResponse = + components["schemas"]["PluginStatusResponse"]; +export type PluginHealthDto = components["schemas"]["PluginHealthDto"]; +export type PluginHealthResponse = + components["schemas"]["PluginHealthResponse"]; +export type PluginManifestDto = components["schemas"]["PluginManifestDto"]; +export type PluginCapabilitiesDto = + components["schemas"]["PluginCapabilitiesDto"]; +export type CredentialFieldDto = components["schemas"]["CredentialFieldDto"]; +export type EnvVarDto = components["schemas"]["EnvVarDto"]; + +// Plugin Actions types +export type PluginActionDto = components["schemas"]["PluginActionDto"]; +export type PluginActionsResponse = + components["schemas"]["PluginActionsResponse"]; +export type ExecutePluginRequest = + components["schemas"]["ExecutePluginRequest"]; +export type ExecutePluginResponse = + components["schemas"]["ExecutePluginResponse"]; +export type MetadataContentType = components["schemas"]["MetadataContentType"]; +export type MetadataAction = components["schemas"]["MetadataAction"]; +export type PluginActionRequest = components["schemas"]["PluginActionRequest"]; +export type PluginSearchResultDto = + components["schemas"]["PluginSearchResultDto"]; +export type SearchResultPreviewDto = + components["schemas"]["SearchResultPreviewDto"]; +export type PluginSearchResponse = + components["schemas"]["PluginSearchResponse"]; + +// Metadata Preview/Apply types +export type MetadataPreviewRequest = + components["schemas"]["MetadataPreviewRequest"]; +export type MetadataPreviewResponse = + components["schemas"]["MetadataPreviewResponse"]; +export type MetadataFieldPreview = + components["schemas"]["MetadataFieldPreview"]; +export type FieldApplyStatus = components["schemas"]["FieldApplyStatus"]; +export type PreviewSummary = components["schemas"]["PreviewSummary"]; +export type MetadataApplyRequest = + components["schemas"]["MetadataApplyRequest"]; +export type MetadataApplyResponse = + components["schemas"]["MetadataApplyResponse"]; +export type SkippedField = components["schemas"]["SkippedField"]; +// Auto-match types (defined manually until OpenAPI types are regenerated) +export interface MetadataAutoMatchRequest { + pluginId: string; + query?: string; +} + +export interface MetadataAutoMatchResponse { + success: boolean; + matchedResult?: PluginSearchResultDto; + appliedFields: string[]; + skippedFields: SkippedField[]; + message: string; + externalUrl?: string; +} + +// Task-based auto-match response +export interface EnqueueAutoMatchResponse { + success: boolean; + tasksEnqueued: number; + taskIds: string[]; + message: string; +} + +// Plugin Failure types +export type PluginFailureDto = components["schemas"]["PluginFailureDto"]; +export type PluginFailuresResponse = + components["schemas"]["PluginFailuresResponse"]; + +// Plugin types +export type PluginType = "system" | "user"; + +// Health status values +export type PluginHealthStatus = + | "unknown" + | "healthy" + | "degraded" + | "unhealthy" + | "disabled"; + +// Credential delivery methods +export type CredentialDelivery = "env" | "init_message" | "both"; + +// Plugin scopes (must match backend PluginScope enum) +export type PluginScope = + | "series:detail" + | "series:bulk" + | "library:detail" + | "library:scan"; + +// Plugin permissions +export type PluginPermission = + | "metadata:read" + | "metadata:write:title" + | "metadata:write:summary" + | "metadata:write:genres" + | "metadata:write:tags" + | "metadata:write:covers" + | "metadata:write:ratings" + | "metadata:write:links" + | "metadata:write:year" + | "metadata:write:status" + | "metadata:write:publisher" + | "metadata:write:age_rating" + | "metadata:write:language" + | "metadata:write:reading_direction" + | "metadata:write:total_book_count" + | "metadata:write:*" + | "library:read"; + +// Available options for forms +export const AVAILABLE_SCOPES: { value: PluginScope; label: string }[] = [ + { value: "series:detail", label: "Series Detail" }, + { value: "series:bulk", label: "Series Bulk Actions" }, + { value: "library:detail", label: "Library Detail" }, + { value: "library:scan", label: "Post-Library Scan" }, +]; + +export const AVAILABLE_PERMISSIONS: { + value: PluginPermission; + label: string; +}[] = [ + { value: "metadata:read", label: "Read Metadata" }, + { value: "metadata:write:*", label: "Write All Metadata" }, + { value: "metadata:write:title", label: "Write Title" }, + { value: "metadata:write:summary", label: "Write Summary" }, + { value: "metadata:write:genres", label: "Write Genres" }, + { value: "metadata:write:tags", label: "Write Tags" }, + { value: "metadata:write:covers", label: "Write Covers" }, + { value: "metadata:write:ratings", label: "Write Ratings" }, + { value: "metadata:write:links", label: "Write Links" }, + { value: "metadata:write:year", label: "Write Year" }, + { value: "metadata:write:status", label: "Write Status" }, + { value: "metadata:write:publisher", label: "Write Publisher" }, + { value: "metadata:write:age_rating", label: "Write Age Rating" }, + { value: "metadata:write:language", label: "Write Language" }, + { + value: "metadata:write:reading_direction", + label: "Write Reading Direction", + }, + { + value: "metadata:write:total_book_count", + label: "Write Total Book Count", + }, + { value: "library:read", label: "Read Library" }, +]; + +export const CREDENTIAL_DELIVERY_OPTIONS: { + value: CredentialDelivery; + label: string; +}[] = [ + { value: "env", label: "Environment Variables" }, + { value: "init_message", label: "Initialize Message" }, + { value: "both", label: "Both" }, +]; + +export const pluginsApi = { + /** + * Get all plugins (Admin only) + */ + getAll: async (): Promise => { + const response = await api.get("/admin/plugins"); + return response.data; + }, + + /** + * Get a single plugin by ID (Admin only) + */ + getById: async (id: string): Promise => { + const response = await api.get(`/admin/plugins/${id}`); + return response.data; + }, + + /** + * Create a new plugin (Admin only) + */ + create: async (request: CreatePluginRequest): Promise => { + const response = await api.post("/admin/plugins", request); + return response.data; + }, + + /** + * Update a plugin (Admin only) + */ + update: async ( + id: string, + request: UpdatePluginRequest, + ): Promise => { + const response = await api.patch( + `/admin/plugins/${id}`, + request, + ); + return response.data; + }, + + /** + * Delete a plugin (Admin only) + */ + delete: async (id: string): Promise => { + await api.delete(`/admin/plugins/${id}`); + }, + + /** + * Enable a plugin (Admin only) + */ + enable: async (id: string): Promise => { + const response = await api.post( + `/admin/plugins/${id}/enable`, + ); + return response.data; + }, + + /** + * Disable a plugin (Admin only) + */ + disable: async (id: string): Promise => { + const response = await api.post( + `/admin/plugins/${id}/disable`, + ); + return response.data; + }, + + /** + * Test a plugin connection (Admin only) + * Spawns the plugin process, sends an initialize request, and returns the manifest. + */ + test: async (id: string): Promise => { + const response = await api.post( + `/admin/plugins/${id}/test`, + ); + return response.data; + }, + + /** + * Get plugin health information (Admin only) + */ + getHealth: async (id: string): Promise => { + const response = await api.get( + `/admin/plugins/${id}/health`, + ); + return response.data; + }, + + /** + * Reset plugin failure count (Admin only) + * Clears the failure count and disabled reason, allowing the plugin to be used again. + */ + resetFailures: async (id: string): Promise => { + const response = await api.post( + `/admin/plugins/${id}/reset`, + ); + return response.data; + }, + + /** + * Get plugin failure history (Admin only) + * Returns failure events with time-window statistics. + */ + getFailures: async ( + id: string, + limit = 20, + offset = 0, + ): Promise => { + const response = await api.get( + `/admin/plugins/${id}/failures`, + { params: { limit, offset } }, + ); + return response.data; + }, + + // ========================================================================== + // Plugin Actions API (User-facing) + // ========================================================================== + + /** + * Get available plugin actions for a specific scope + * @param scope - The scope to filter actions by (e.g., "series:detail") + * @param libraryId - Optional library ID to filter plugins by (only plugins that apply to this library) + */ + getActions: async ( + scope: PluginScope, + libraryId?: string, + ): Promise => { + const params = new URLSearchParams({ scope }); + if (libraryId) { + params.set("libraryId", libraryId); + } + const response = await api.get( + `/plugins/actions?${params.toString()}`, + ); + return response.data; + }, + + /** + * Execute a plugin action + * Backend maps action + contentType to the appropriate protocol method + */ + execute: async ( + pluginId: string, + request: ExecutePluginRequest, + ): Promise => { + const response = await api.post( + `/plugins/${pluginId}/execute`, + request, + ); + return response.data; + }, + + /** + * Search for metadata using a plugin + */ + searchMetadata: async ( + pluginId: string, + query: string, + contentType: MetadataContentType = "series", + ): Promise => { + return pluginsApi.execute(pluginId, { + action: { + metadata: { + action: "search", + content_type: contentType, + params: { query }, + }, + }, + }); + }, + + /** + * Get full metadata from a plugin by external ID + */ + getMetadata: async ( + pluginId: string, + externalId: string, + contentType: MetadataContentType = "series", + ): Promise => { + return pluginsApi.execute(pluginId, { + action: { + metadata: { + action: "get", + content_type: contentType, + params: { externalId }, + }, + }, + }); + }, +}; + +/** + * Plugin Actions API for metadata operations on series and books + */ +export const pluginActionsApi = { + /** + * Preview metadata from a plugin for a series (dry run) + * Returns field-by-field diff with status icons + */ + previewSeriesMetadata: async ( + seriesId: string, + pluginId: string, + externalId: string, + ): Promise => { + const response = await api.post( + `/series/${seriesId}/metadata/preview`, + { pluginId, externalId }, + ); + return response.data; + }, + + /** + * Apply metadata from a plugin to a series + * Respects RBAC permissions and field locks + */ + applySeriesMetadata: async ( + seriesId: string, + pluginId: string, + externalId: string, + fields?: string[], + ): Promise => { + const response = await api.post( + `/series/${seriesId}/metadata/apply`, + { pluginId, externalId, fields }, + ); + return response.data; + }, + + /** + * Auto-match and apply metadata from a plugin to a series + * Searches for the best match and applies metadata in one step + */ + autoMatchSeriesMetadata: async ( + seriesId: string, + pluginId: string, + query?: string, + ): Promise => { + const response = await api.post( + `/series/${seriesId}/metadata/auto-match`, + { pluginId, query }, + ); + return response.data; + }, + + // ========================================================================== + // Task-based Auto-Match API (Background Processing) + // ========================================================================== + + /** + * Enqueue an auto-match task for a single series + * Runs asynchronously in a worker process + */ + enqueueAutoMatchTask: async ( + seriesId: string, + pluginId: string, + ): Promise => { + const response = await api.post( + `/series/${seriesId}/metadata/auto-match/task`, + { pluginId }, + ); + return response.data; + }, + + /** + * Enqueue auto-match tasks for multiple series (bulk operation) + * Each series gets its own task + */ + enqueueBulkAutoMatchTasks: async ( + pluginId: string, + seriesIds: string[], + ): Promise => { + const response = await api.post( + "/series/metadata/auto-match/task/bulk", + { pluginId, seriesIds }, + ); + return response.data; + }, + + /** + * Enqueue auto-match tasks for all series in a library + * Creates a task for each series in the library + */ + enqueueLibraryAutoMatchTasks: async ( + libraryId: string, + pluginId: string, + ): Promise => { + const response = await api.post( + `/libraries/${libraryId}/metadata/auto-match/task`, + { pluginId }, + ); + return response.data; + }, +}; diff --git a/web/src/api/readProgress.test.ts b/web/src/api/readProgress.test.ts index 7d524be4..f578814e 100644 --- a/web/src/api/readProgress.test.ts +++ b/web/src/api/readProgress.test.ts @@ -61,13 +61,11 @@ describe("readProgressApi", () => { vi.mocked(api.put).mockResolvedValueOnce({ data: mockProgress }); const result = await readProgressApi.update("book-123", { - currentPage: 50, + current_page: 50, }); expect(api.put).toHaveBeenCalledWith("/books/book-123/progress", { current_page: 50, - progress_percentage: undefined, - completed: false, }); expect(result).toEqual(mockProgress); }); @@ -84,13 +82,12 @@ describe("readProgressApi", () => { vi.mocked(api.put).mockResolvedValueOnce({ data: mockProgress }); const result = await readProgressApi.update("book-123", { - currentPage: 100, + current_page: 100, completed: true, }); expect(api.put).toHaveBeenCalledWith("/books/book-123/progress", { current_page: 100, - progress_percentage: undefined, completed: true, }); expect(result).toEqual(mockProgress); @@ -108,14 +105,13 @@ describe("readProgressApi", () => { vi.mocked(api.put).mockResolvedValueOnce({ data: mockProgress }); const result = await readProgressApi.update("book-123", { - currentPage: 45, - progressPercentage: 0.45, + current_page: 45, + progress_percentage: 0.45, }); expect(api.put).toHaveBeenCalledWith("/books/book-123/progress", { current_page: 45, progress_percentage: 0.45, - completed: false, }); expect(result).toEqual(mockProgress); }); diff --git a/web/src/api/readProgress.ts b/web/src/api/readProgress.ts index c4aca207..a97e0be0 100644 --- a/web/src/api/readProgress.ts +++ b/web/src/api/readProgress.ts @@ -3,13 +3,8 @@ import { api } from "./client"; export type ReadProgressResponse = components["schemas"]["ReadProgressResponse"]; - -export interface UpdateProgressRequest { - currentPage: number; - /** Progress as percentage (0.0-1.0), used for EPUB books with reflowable content */ - progressPercentage?: number; - completed?: boolean; -} +export type UpdateProgressRequest = + components["schemas"]["UpdateProgressRequest"]; export const readProgressApi = { /** @@ -41,11 +36,7 @@ export const readProgressApi = { ): Promise => { const response = await api.put( `/books/${bookId}/progress`, - { - current_page: request.currentPage, - progress_percentage: request.progressPercentage, - completed: request.completed ?? false, - }, + request, ); return response.data; }, diff --git a/web/src/api/series.ts b/web/src/api/series.ts index 45434eaa..56a4e7cf 100644 --- a/web/src/api/series.ts +++ b/web/src/api/series.ts @@ -103,13 +103,35 @@ export const seriesApi = { return response.data; }, - // Generate thumbnails for all books in series (queues a background task) - generateThumbnails: async ( + // Generate missing thumbnails for books in series (queues a background task) + generateMissingBookThumbnails: async ( seriesId: string, ): Promise<{ task_id: string }> => { const response = await api.post<{ task_id: string }>( - `/series/${seriesId}/thumbnails/generate`, - { force: true }, + "/books/thumbnails/generate", + { series_id: seriesId, force: false }, + ); + return response.data; + }, + + // Regenerate all thumbnails for books in series (queues a background task) + regenerateBookThumbnails: async ( + seriesId: string, + ): Promise<{ task_id: string }> => { + const response = await api.post<{ task_id: string }>( + "/books/thumbnails/generate", + { series_id: seriesId, force: true }, + ); + return response.data; + }, + + // Generate series cover thumbnail if missing (from first book's cover) + generateSeriesThumbnailIfMissing: async ( + seriesId: string, + ): Promise<{ task_id: string }> => { + const response = await api.post<{ task_id: string }>( + `/series/${seriesId}/thumbnail/generate`, + { force: false }, ); return response.data; }, @@ -117,11 +139,10 @@ export const seriesApi = { // Regenerate the series cover thumbnail (from first book's cover) regenerateSeriesThumbnail: async ( seriesId: string, - force = true, ): Promise<{ task_id: string }> => { const response = await api.post<{ task_id: string }>( `/series/${seriesId}/thumbnail/generate`, - { force }, + { force: true }, ); return response.data; }, @@ -324,6 +345,52 @@ export const seriesApi = { ); return response.data; }, + + // ==================== Bulk Operations API ==================== + + /** + * Mark all books in multiple series as read in bulk + * @param seriesIds - Array of series IDs to mark as read + */ + bulkMarkAsRead: async ( + seriesIds: string[], + ): Promise<{ count: number; message: string }> => { + const response = await api.post<{ count: number; message: string }>( + "/series/bulk/read", + { seriesIds }, + ); + return response.data; + }, + + /** + * Mark all books in multiple series as unread in bulk + * @param seriesIds - Array of series IDs to mark as unread + */ + bulkMarkAsUnread: async ( + seriesIds: string[], + ): Promise<{ count: number; message: string }> => { + const response = await api.post<{ count: number; message: string }>( + "/series/bulk/unread", + { seriesIds }, + ); + return response.data; + }, + + /** + * Queue analysis for all books in multiple series in bulk + * @param seriesIds - Array of series IDs to analyze + * @param force - Whether to force re-analysis of already analyzed books (default: true) + */ + bulkAnalyze: async ( + seriesIds: string[], + force = true, + ): Promise<{ tasksEnqueued: number; message: string }> => { + const response = await api.post<{ tasksEnqueued: number; message: string }>( + "/series/bulk/analyze", + { seriesIds, force }, + ); + return response.data; + }, }; /** Alphabetical group with count */ diff --git a/web/src/api/settings.ts b/web/src/api/settings.ts index 97371083..011e95cc 100644 --- a/web/src/api/settings.ts +++ b/web/src/api/settings.ts @@ -13,11 +13,8 @@ export type BrandingSettingsDto = components["schemas"]["BrandingSettingsDto"]; // Bulk update returns an array of SettingDto export type BulkUpdateSettingsResponse = SettingDto[]; -// Public setting type (simplified, for non-admin users) -export interface PublicSettingDto { - key: string; - value: string; -} +// Re-export generated public setting type +export type PublicSettingDto = components["schemas"]["PublicSettingDto"]; // Map of setting key to public setting export type PublicSettingsMap = Record; diff --git a/web/src/api/tasks.ts b/web/src/api/tasks.ts index 7e304b4e..4f22b556 100644 --- a/web/src/api/tasks.ts +++ b/web/src/api/tasks.ts @@ -1,36 +1,22 @@ -import type { TaskProgressEvent, TaskResponse } from "@/types"; +import type { components, TaskProgressEvent, TaskResponse } from "@/types"; // Re-export TaskResponse for consumers export type { TaskResponse }; -interface TaskProgressReconnectionManager { - connect: () => Promise<() => void>; - disconnect: () => void; -} - -export interface TaskTypeStats { - pending: number; - processing: number; - completed: number; - failed: number; - stale: number; - total: number; -} - -export interface TaskStats { - pending: number; - processing: number; - completed: number; - failed: number; - stale: number; - total: number; - by_type: { [taskType: string]: TaskTypeStats }; -} +// Re-export generated types for convenience +export type TaskTypeStats = components["schemas"]["TaskTypeStats"]; +export type TaskStats = components["schemas"]["TaskStats"]; +// Custom type for pending counts (not in generated types) export interface PendingTaskCounts { [taskType: string]: number; } +interface TaskProgressReconnectionManager { + connect: () => Promise<() => void>; + disconnect: () => void; +} + /** * Fetch tasks with a specific status * diff --git a/web/src/components/book/BookInfoModal.test.tsx b/web/src/components/book/BookInfoModal.test.tsx new file mode 100644 index 00000000..e4d2d251 --- /dev/null +++ b/web/src/components/book/BookInfoModal.test.tsx @@ -0,0 +1,338 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderWithProviders, screen, userEvent } from "@/test/utils"; +import type { Book, ReadProgress } from "@/types"; +import { BookInfoModal } from "./BookInfoModal"; + +// Create a mock book with all required fields +const createMockBook = (overrides?: Partial): Book => ({ + id: "book-123", + title: "Test Book", + fileFormat: "cbz", + fileSize: 52428800, // 50 MB + pageCount: 200, + fileHash: "abc123def456ghi789jkl012mno345pqr678", + filePath: "/library/comics/Test Book/issue-01.cbz", + libraryId: "lib-1", + libraryName: "Comics", + seriesId: "series-1", + seriesName: "Test Series", + createdAt: "2024-06-15T12:00:00Z", + updatedAt: "2024-06-16T14:30:00Z", + analysisError: null, + number: 1, + readProgress: null, + deleted: false, + ...overrides, +}); + +const createMockReadProgress = ( + overrides?: Partial, +): ReadProgress => ({ + id: "progress-1", + book_id: "book-123", + user_id: "user-123", + current_page: 50, + completed: false, + completed_at: null, + progress_percentage: 0.25, + started_at: "2024-06-15T10:00:00Z", + updated_at: "2024-06-15T12:00:00Z", + ...overrides, +}); + +describe("BookInfoModal", () => { + it("should not render when closed", () => { + renderWithProviders( + , + ); + + expect(screen.queryByText("Book Information")).not.toBeInTheDocument(); + }); + + it("should render modal title when opened", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Book Information")).toBeInTheDocument(); + }); + + it("should display basic book information", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Basic Information")).toBeInTheDocument(); + expect(screen.getByText("Batman: Year One")).toBeInTheDocument(); + expect(screen.getByText("1")).toBeInTheDocument(); + expect(screen.getByText("Batman")).toBeInTheDocument(); + expect(screen.getByText("Comics")).toBeInTheDocument(); + }); + + it("should display file information", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("File Information")).toBeInTheDocument(); + expect(screen.getByText("EPUB")).toBeInTheDocument(); + expect(screen.getByText("10.00 MB")).toBeInTheDocument(); + expect(screen.getByText("150")).toBeInTheDocument(); + }); + + it("should display file path and hash with copy buttons", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("/path/to/book.cbz")).toBeInTheDocument(); + expect(screen.getByText("abcdef123456")).toBeInTheDocument(); + // Copy buttons exist - check by the Tabler icon SVG or button presence + const copyButtons = document.querySelectorAll( + 'button[class*="ActionIcon"]', + ); + expect(copyButtons.length).toBeGreaterThanOrEqual(2); + }); + + it("should display reading progress when present", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Reading Progress")).toBeInTheDocument(); + expect(screen.getByText("50 / 200")).toBeInTheDocument(); + expect(screen.getByText("25%")).toBeInTheDocument(); + expect(screen.getByText("In Progress")).toBeInTheDocument(); + }); + + it("should display completed status for finished books", () => { + renderWithProviders( + , + ); + + // There should be a "Completed" badge in the Status row + const badges = screen.getAllByText("Completed"); + expect(badges.length).toBeGreaterThanOrEqual(1); + }); + + it("should not display reading progress section when no progress", () => { + renderWithProviders( + , + ); + + expect(screen.queryByText("Reading Progress")).not.toBeInTheDocument(); + }); + + it("should display timestamps and status", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Timestamps & Status")).toBeInTheDocument(); + expect(screen.getByText("Added")).toBeInTheDocument(); + expect(screen.getByText("Updated")).toBeInTheDocument(); + expect(screen.getByText("Active")).toBeInTheDocument(); + }); + + it("should display deleted status for soft-deleted books", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Deleted")).toBeInTheDocument(); + }); + + it("should display identifiers section with copyable IDs", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Identifiers")).toBeInTheDocument(); + expect(screen.getByText("book-uuid-123")).toBeInTheDocument(); + expect(screen.getByText("series-uuid-456")).toBeInTheDocument(); + expect(screen.getByText("library-uuid-789")).toBeInTheDocument(); + }); + + it("should display analysis error when present", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Analysis Error")).toBeInTheDocument(); + expect( + screen.getByText("Failed to parse CBZ: invalid archive"), + ).toBeInTheDocument(); + }); + + it("should not display analysis error section when no error", () => { + renderWithProviders( + , + ); + + expect(screen.queryByText("Analysis Error")).not.toBeInTheDocument(); + }); + + it("should display reading direction when set", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Reading Direction")).toBeInTheDocument(); + expect(screen.getByText("Right to Left")).toBeInTheDocument(); + }); + + it("should display sort title when set", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Sort Title")).toBeInTheDocument(); + expect(screen.getByText("batman year one 001")).toBeInTheDocument(); + }); + + it("should call onClose when modal is closed", async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + + renderWithProviders( + , + ); + + // Click the close button (X) in the modal header - it has the Modal-close class + const closeButton = document.querySelector(".mantine-Modal-close"); + expect(closeButton).toBeInTheDocument(); + if (closeButton) { + await user.click(closeButton); + } + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("should format file sizes correctly", () => { + // Test GB + const { rerender } = renderWithProviders( + , + ); + expect(screen.getByText("2.00 GB")).toBeInTheDocument(); + + // Test MB + rerender( + , + ); + expect(screen.getByText("50.00 MB")).toBeInTheDocument(); + + // Test KB + rerender( + , + ); + expect(screen.getByText("500.00 KB")).toBeInTheDocument(); + + // Test bytes + rerender( + , + ); + expect(screen.getByText("500 B")).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/book/BookInfoModal.tsx b/web/src/components/book/BookInfoModal.tsx new file mode 100644 index 00000000..ec370d02 --- /dev/null +++ b/web/src/components/book/BookInfoModal.tsx @@ -0,0 +1,286 @@ +import { + ActionIcon, + Badge, + Code, + CopyButton, + Group, + Modal, + Paper, + Stack, + Text, + Tooltip, +} from "@mantine/core"; +import { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react"; +import type { Book } from "@/types"; + +export interface BookInfoModalProps { + opened: boolean; + onClose: () => void; + book: Book; +} + +function formatFileSize(bytes: number): string { + if (bytes >= 1073741824) { + return `${(bytes / 1073741824).toFixed(2)} GB`; + } + if (bytes >= 1048576) { + return `${(bytes / 1048576).toFixed(2)} MB`; + } + if (bytes >= 1024) { + return `${(bytes / 1024).toFixed(2)} KB`; + } + return `${bytes} B`; +} + +function formatDateTime(dateString: string): string { + return new Date(dateString).toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +interface InfoRowProps { + label: string; + value: string | number | null | undefined; + copyable?: boolean; + monospace?: boolean; +} + +function InfoRow({ label, value, copyable, monospace }: InfoRowProps) { + if (value === null || value === undefined || value === "") return null; + + const displayValue = String(value); + + // For copyable monospace values (path, hash, IDs), show inline with copy button + if (copyable && monospace) { + return ( + + + {label} + + + + {displayValue} + + + {({ copied, copy }) => ( + + + {copied ? : } + + + )} + + + + ); + } + + return ( + + + {label} + + + {displayValue} + + + ); +} + +export function BookInfoModal({ opened, onClose, book }: BookInfoModalProps) { + return ( + + + Book Information + + } + size="lg" + centered + zIndex={1000} + overlayProps={{ + backgroundOpacity: 0.55, + blur: 3, + }} + > + + {/* Basic Info */} + + + + Basic Information + + + + + + {book.titleSort && ( + + )} + {book.readingDirection && ( + + )} + + + + {/* File Info */} + + + + File Information + + + + + + + + + + {/* Read Progress */} + {book.readProgress && ( + + + + Reading Progress + + + {book.readProgress.progress_percentage !== null && + book.readProgress.progress_percentage !== undefined && ( + + )} + + + Status + + + {book.readProgress.completed ? "Completed" : "In Progress"} + + + + {book.readProgress.completed && + book.readProgress.completed_at && ( + + )} + + + + )} + + {/* Timestamps & Status */} + + + + Timestamps & Status + + + + + + Status + + + {book.deleted ? "Deleted" : "Active"} + + + + + + {/* IDs */} + + + + Identifiers + + + + + + + + {/* Analysis Error */} + {book.analysisError && ( + + + + Analysis Error + + + {book.analysisError} + + + + )} + + + ); +} diff --git a/web/src/components/book/index.ts b/web/src/components/book/index.ts index 53adeff2..4ea51e34 100644 --- a/web/src/components/book/index.ts +++ b/web/src/components/book/index.ts @@ -1,3 +1,4 @@ export { BookFileInfo } from "./BookFileInfo"; +export { BookInfoModal } from "./BookInfoModal"; export { BookMetadataDisplay } from "./BookMetadataDisplay"; export { BookProgress } from "./BookProgress"; diff --git a/web/src/components/layout/AppLayout.tsx b/web/src/components/layout/AppLayout.tsx index 640e5342..f6d2be6d 100644 --- a/web/src/components/layout/AppLayout.tsx +++ b/web/src/components/layout/AppLayout.tsx @@ -4,6 +4,7 @@ import { useRef } from "react"; import type { SearchInputHandle } from "@/components/search"; import { useSearchShortcut } from "@/hooks/useSearchShortcut"; import { Header } from "./Header"; +import { PluginStatusBanner } from "./PluginStatusBanner"; import { Sidebar } from "./Sidebar"; interface AppLayoutProps { @@ -37,7 +38,10 @@ export function AppLayout({ children, currentPath }: AppLayoutProps) { /> - {children} + + + {children} + ); } diff --git a/web/src/components/layout/PluginStatusBanner.tsx b/web/src/components/layout/PluginStatusBanner.tsx new file mode 100644 index 00000000..529396d2 --- /dev/null +++ b/web/src/components/layout/PluginStatusBanner.tsx @@ -0,0 +1,123 @@ +import { Alert, Anchor, Group, Text } from "@mantine/core"; +import { IconPlugConnectedX } from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; +import { useCallback, useState } from "react"; +import { Link } from "react-router-dom"; +import { pluginsApi } from "@/api/plugins"; +import { useAuthStore } from "@/store/authStore"; + +// Session storage key for dismissed plugins +const DISMISSED_KEY = "codex:dismissed-plugin-alerts"; + +/** + * Get the set of dismissed plugin IDs from session storage. + */ +function getDismissedPluginIds(): Set { + try { + const stored = sessionStorage.getItem(DISMISSED_KEY); + if (stored) { + return new Set(JSON.parse(stored)); + } + } catch { + // Ignore parsing errors + } + return new Set(); +} + +/** + * Add a plugin ID to the dismissed set in session storage. + */ +function dismissPlugin(pluginId: string): void { + const dismissed = getDismissedPluginIds(); + dismissed.add(pluginId); + sessionStorage.setItem(DISMISSED_KEY, JSON.stringify([...dismissed])); +} + +/** + * A global banner that shows when plugins are disabled due to failures. + * Only visible to admin users. Does not show for manually disabled plugins. + */ +export function PluginStatusBanner() { + const { user } = useAuthStore(); + const isAdmin = user?.role === "admin"; + const [dismissedIds, setDismissedIds] = useState>( + getDismissedPluginIds, + ); + + const { data: pluginsResponse } = useQuery({ + queryKey: ["plugins"], + queryFn: pluginsApi.getAll, + // Only fetch if user is admin + enabled: isAdmin, + // Don't refetch too aggressively for this status check + refetchInterval: 60000, // 1 minute + staleTime: 30000, // 30 seconds + }); + + const handleDismissAll = useCallback(() => { + if (!pluginsResponse) return; + const failedPlugins = pluginsResponse.plugins.filter( + (p) => + p.disabledReason || + (p.healthStatus === "unhealthy" && p.failureCount > 0), + ); + for (const plugin of failedPlugins) { + dismissPlugin(plugin.id); + } + setDismissedIds( + (prev) => new Set([...prev, ...failedPlugins.map((p) => p.id)]), + ); + }, [pluginsResponse]); + + // Don't show for non-admins or if no data + if (!isAdmin || !pluginsResponse) { + return null; + } + + // Find plugins that are disabled due to failures (have disabledReason set) + // or are unhealthy with failures. This excludes manually disabled plugins. + const failedPlugins = pluginsResponse.plugins.filter( + (p) => + // Plugin was auto-disabled due to failures (has a reason) + p.disabledReason || + // Plugin is unhealthy and has failure count (but not manually disabled) + (p.healthStatus === "unhealthy" && p.failureCount > 0 && p.enabled), + ); + + // Filter out dismissed plugins + const visiblePlugins = failedPlugins.filter((p) => !dismissedIds.has(p.id)); + + if (visiblePlugins.length === 0) { + return null; + } + + const pluginNames = visiblePlugins + .map((p) => p.displayName) + .slice(0, 3) + .join(", "); + const moreCount = visiblePlugins.length - 3; + + return ( + } + color="red" + variant="light" + radius={0} + style={{ borderBottom: "1px solid var(--mantine-color-red-3)" }} + withCloseButton + onClose={handleDismissAll} + closeButtonLabel="Dismiss all" + > + + + {visiblePlugins.length === 1 + ? `Plugin "${pluginNames}" is disabled due to failures.` + : `${visiblePlugins.length} plugins are having issues: ${pluginNames}${moreCount > 0 ? ` and ${moreCount} more` : ""}.`} + + + View Plugins + + + + ); +} diff --git a/web/src/components/layout/Sidebar.test.tsx b/web/src/components/layout/Sidebar.test.tsx index df8d34a9..b6eeed76 100644 --- a/web/src/components/layout/Sidebar.test.tsx +++ b/web/src/components/layout/Sidebar.test.tsx @@ -1,9 +1,9 @@ -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { librariesApi } from "@/api/libraries"; import { useAuthStore } from "@/store/authStore"; import { renderWithProviders, userEvent } from "@/test/utils"; -import type { User } from "@/types"; +import type { Library, User } from "@/types"; import { AppLayout } from "./AppLayout"; vi.mock("@/api/libraries"); @@ -12,6 +12,14 @@ vi.mock("@/api/tasks", () => ({ fetchPendingTaskCounts: vi.fn(() => Promise.resolve({})), fetchTasksByStatus: vi.fn(() => Promise.resolve([])), })); +vi.mock("@/api/plugins", () => ({ + pluginsApi: { + getActions: vi.fn(() => Promise.resolve({ actions: [] })), + }, + pluginActionsApi: { + enqueueLibraryAutoMatchTasks: vi.fn(), + }, +})); describe("Sidebar Component (via AppLayout)", () => { beforeEach(() => { @@ -224,4 +232,253 @@ describe("Sidebar Component (via AppLayout)", () => { // Should clear auth (navigation is handled by React Router now) expect(localStorage.getItem("jwt_token")).toBeNull(); }); + + describe("Settings navigation", () => { + it("should open Settings menu when navigating to a settings page", async () => { + const mockAdmin: User = { + id: "1", + username: "admin", + email: "admin@example.com", + role: "admin", + emailVerified: true, + permissions: [], + }; + + useAuthStore.setState({ + user: mockAdmin, + token: "token", + isAuthenticated: true, + }); + + // Render starting from home page + const { rerender } = renderWithProviders( + +

Content
+ , + ); + + // Settings submenu items (like Plugins) should not be visible when Settings is collapsed + // Mantine NavLink keeps children in DOM but hides them visually + const pluginsLinkBefore = screen.getByText("Plugins"); + expect(pluginsLinkBefore).not.toBeVisible(); + + // Now rerender with a settings path to simulate navigation + rerender( + +
Content
+
, + ); + + // After navigation to settings page, the Settings menu should be expanded + // and Plugins submenu item should be visible + await waitFor(() => { + expect(screen.getByText("Plugins")).toBeVisible(); + }); + }); + + it("should have Settings menu open when starting on a settings page", () => { + const mockAdmin: User = { + id: "1", + username: "admin", + email: "admin@example.com", + role: "admin", + emailVerified: true, + permissions: [], + }; + + useAuthStore.setState({ + user: mockAdmin, + token: "token", + isAuthenticated: true, + }); + + // Render directly on a settings page + renderWithProviders( + +
Content
+
, + ); + + // Settings submenu items should be visible since we started on a settings page + expect(screen.getByText("Plugins")).toBeVisible(); + }); + }); + + describe("Library dropdown menu", () => { + const mockLibrary: Library = { + id: "lib-123", + name: "Test Library", + path: "/test/path", + isActive: true, + createdAt: "2024-01-01", + updatedAt: "2024-01-01", + bookStrategy: "filename", + defaultReadingDirection: "ltr", + numberStrategy: "filename", + seriesStrategy: "flat", + }; + + it("should show thumbnail options for users with tasks:write permission", async () => { + const user = userEvent.setup(); + const mockMaintainer: User = { + id: "1", + username: "maintainer", + email: "maintainer@example.com", + role: "maintainer", + emailVerified: true, + permissions: ["libraries-write", "tasks-write"], + }; + + useAuthStore.setState({ + user: mockMaintainer, + token: "token", + isAuthenticated: true, + }); + + vi.mocked(librariesApi.getAll).mockResolvedValue([mockLibrary]); + + renderWithProviders( + +
Content
+
, + ); + + // Wait for the library to appear + await waitFor(() => { + expect(screen.getByText("Test Library")).toBeInTheDocument(); + }); + + // Find and click the library options button (the dots icon) + const libraryItem = screen.getByText("Test Library").closest("a"); + const optionsButton = libraryItem?.querySelector( + 'button[title="Library options"]', + ); + expect(optionsButton).toBeInTheDocument(); + + if (optionsButton) { + await user.click(optionsButton); + } + + // Should show thumbnail sections with options + await waitFor(() => { + expect(screen.getByText("Book Thumbnails")).toBeInTheDocument(); + }); + expect(screen.getByText("Series Thumbnails")).toBeInTheDocument(); + // There should be two "Generate Missing" and two "Regenerate All" options + expect(screen.getAllByText("Generate Missing")).toHaveLength(2); + expect(screen.getAllByText("Regenerate All")).toHaveLength(2); + }); + + it("should NOT show thumbnail options for users without tasks:write permission", async () => { + const user = userEvent.setup(); + // Use a reader role with only libraries-write custom permission (no tasks-write) + const mockEditor: User = { + id: "1", + username: "editor", + email: "editor@example.com", + role: "reader", + emailVerified: true, + permissions: ["libraries-write"], // Can edit libraries but not write tasks + }; + + useAuthStore.setState({ + user: mockEditor, + token: "token", + isAuthenticated: true, + }); + + vi.mocked(librariesApi.getAll).mockResolvedValue([mockLibrary]); + + renderWithProviders( + +
Content
+
, + ); + + // Wait for the library to appear + await waitFor(() => { + expect(screen.getByText("Test Library")).toBeInTheDocument(); + }); + + // Find and click the library options button + const libraryItem = screen.getByText("Test Library").closest("a"); + const optionsButton = libraryItem?.querySelector( + 'button[title="Library options"]', + ); + expect(optionsButton).toBeInTheDocument(); + + if (optionsButton) { + await user.click(optionsButton); + } + + // Wait for menu to open and check that thumbnail options are NOT shown + await waitFor(() => { + expect(screen.getByText("Scan Library")).toBeInTheDocument(); + }); + + expect(screen.queryByText("Book Thumbnails")).not.toBeInTheDocument(); + expect(screen.queryByText("Series Thumbnails")).not.toBeInTheDocument(); + }); + + it("should call generateMissingThumbnails API when clicking the menu item", async () => { + const user = userEvent.setup(); + const mockAdmin: User = { + id: "1", + username: "admin", + email: "admin@example.com", + role: "admin", + emailVerified: true, + permissions: [], + }; + + useAuthStore.setState({ + user: mockAdmin, + token: "token", + isAuthenticated: true, + }); + + vi.mocked(librariesApi.getAll).mockResolvedValue([mockLibrary]); + vi.mocked(librariesApi.generateMissingThumbnails).mockResolvedValue({ + task_id: "task-123", + }); + + renderWithProviders( + +
Content
+
, + ); + + // Wait for the library to appear + await waitFor(() => { + expect(screen.getByText("Test Library")).toBeInTheDocument(); + }); + + // Find and click the library options button + const libraryItem = screen.getByText("Test Library").closest("a"); + const optionsButton = libraryItem?.querySelector( + 'button[title="Library options"]', + ); + expect(optionsButton).toBeInTheDocument(); + + if (optionsButton) { + await user.click(optionsButton); + } + + // Wait for menu to open + await waitFor(() => { + expect(screen.getByText("Book Thumbnails")).toBeInTheDocument(); + }); + + // Click the Generate Missing option under Book Thumbnails + const generateMissingButtons = screen.getAllByText("Generate Missing"); + await user.click(generateMissingButtons[0]); + + // Verify the API was called with the correct library ID + await waitFor(() => { + expect(librariesApi.generateMissingThumbnails).toHaveBeenCalledWith( + "lib-123", + ); + }); + }); + }); }); diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx index 60bca500..0309dbe3 100644 --- a/web/src/components/layout/Sidebar.tsx +++ b/web/src/components/layout/Sidebar.tsx @@ -23,6 +23,8 @@ import { IconFileTypePdf, IconHome, IconLogout, + IconPhoto, + IconPlugConnected, IconPlus, IconRadar, IconScan, @@ -33,11 +35,17 @@ import { IconTrashX, IconUser, IconUsers, + IconWand, } from "@tabler/icons-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { librariesApi } from "@/api/libraries"; +import { + type PluginActionDto, + pluginActionsApi, + pluginsApi, +} from "@/api/plugins"; import { LibraryModal } from "@/components/forms/LibraryModal"; import { TaskNotificationBadge } from "@/components/TaskNotificationBadge"; import { useAppInfo } from "@/hooks/useAppInfo"; @@ -63,6 +71,7 @@ export function Sidebar({ currentPath = "/" }: SidebarProps) { const { isAdmin, hasPermission } = usePermissions(); const canEditLibrary = hasPermission(PERMISSIONS.LIBRARIES_WRITE); const canDeleteLibrary = hasPermission(PERMISSIONS.LIBRARIES_DELETE); + const canWriteTasks = hasPermission(PERMISSIONS.TASKS_WRITE); const [addLibraryOpened, setAddLibraryOpened] = useState(false); const [editLibraryOpened, setEditLibraryOpened] = useState(false); const [selectedLibrary, setSelectedLibrary] = useState(null); @@ -74,11 +83,26 @@ export function Sidebar({ currentPath = "/" }: SidebarProps) { currentPath.startsWith("/settings"), ); + // Sync settingsOpened state when navigating to/from settings pages + useEffect(() => { + if (currentPath.startsWith("/settings")) { + setSettingsOpened(true); + } + }, [currentPath]); + const { data: libraries } = useQuery({ queryKey: ["libraries"], queryFn: librariesApi.getAll, }); + // Fetch available plugin actions for library:detail scope + const { data: pluginActions } = useQuery({ + queryKey: ["plugin-actions", "library:detail"], + queryFn: () => pluginsApi.getActions("library:detail"), + staleTime: 5 * 60 * 1000, // Cache for 5 minutes + enabled: canEditLibrary, // Only fetch if user can edit libraries + }); + const scanMutation = useMutation({ mutationFn: ({ libraryId, @@ -149,6 +173,120 @@ export function Sidebar({ currentPath = "/" }: SidebarProps) { }, }); + // Auto-match mutation for library-wide metadata matching + const autoMatchMutation = useMutation({ + mutationFn: ({ + libraryId, + pluginId, + }: { + libraryId: string; + pluginId: string; + }) => pluginActionsApi.enqueueLibraryAutoMatchTasks(libraryId, pluginId), + onSuccess: (data) => { + if (data.success) { + notifications.show({ + title: "Auto-match started", + message: data.message, + color: "blue", + }); + } else { + notifications.show({ + title: "Auto-match", + message: data.message, + color: "yellow", + }); + } + }, + onError: (error: Error) => { + notifications.show({ + title: "Auto-match failed", + message: error.message || "Failed to start auto-match", + color: "red", + }); + }, + }); + + // Generate missing thumbnails mutation + const generateMissingThumbnailsMutation = useMutation({ + mutationFn: (libraryId: string) => + librariesApi.generateMissingThumbnails(libraryId), + onSuccess: () => { + notifications.show({ + title: "Thumbnail generation started", + message: "Missing thumbnails are being generated", + color: "blue", + }); + }, + onError: (error: Error) => { + notifications.show({ + title: "Thumbnail generation failed", + message: error.message || "Failed to start thumbnail generation", + color: "red", + }); + }, + }); + + // Regenerate all thumbnails mutation (force) + const regenerateAllThumbnailsMutation = useMutation({ + mutationFn: (libraryId: string) => + librariesApi.regenerateAllThumbnails(libraryId), + onSuccess: () => { + notifications.show({ + title: "Thumbnail regeneration started", + message: "All book thumbnails are being regenerated", + color: "blue", + }); + }, + onError: (error: Error) => { + notifications.show({ + title: "Thumbnail regeneration failed", + message: error.message || "Failed to start thumbnail regeneration", + color: "red", + }); + }, + }); + + // Generate missing series thumbnails mutation + const generateMissingSeriesThumbnailsMutation = useMutation({ + mutationFn: (libraryId: string) => + librariesApi.generateMissingSeriesThumbnails(libraryId), + onSuccess: () => { + notifications.show({ + title: "Series thumbnail generation started", + message: "Missing series thumbnails are being generated", + color: "blue", + }); + }, + onError: (error: Error) => { + notifications.show({ + title: "Series thumbnail generation failed", + message: error.message || "Failed to start series thumbnail generation", + color: "red", + }); + }, + }); + + // Regenerate all series thumbnails mutation (force) + const regenerateAllSeriesThumbnailsMutation = useMutation({ + mutationFn: (libraryId: string) => + librariesApi.regenerateAllSeriesThumbnails(libraryId), + onSuccess: () => { + notifications.show({ + title: "Series thumbnail regeneration started", + message: "All series thumbnails are being regenerated", + color: "blue", + }); + }, + onError: (error: Error) => { + notifications.show({ + title: "Series thumbnail regeneration failed", + message: + error.message || "Failed to start series thumbnail regeneration", + color: "red", + }); + }, + }); + const handleScanAll = (mode: "normal" | "deep") => { if (!libraries) return; @@ -157,6 +295,57 @@ export function Sidebar({ currentPath = "/" }: SidebarProps) { }); }; + const handleGenerateAllMissingThumbnails = () => { + if (!libraries) return; + + libraries.forEach((library) => { + generateMissingThumbnailsMutation.mutate(library.id); + }); + }; + + const handleRegenerateAllThumbnails = () => { + if (!libraries) return; + + libraries.forEach((library) => { + regenerateAllThumbnailsMutation.mutate(library.id); + }); + }; + + const handleGenerateAllMissingSeriesThumbnails = () => { + if (!libraries) return; + + libraries.forEach((library) => { + generateMissingSeriesThumbnailsMutation.mutate(library.id); + }); + }; + + const handleRegenerateAllSeriesThumbnails = () => { + if (!libraries) return; + + libraries.forEach((library) => { + regenerateAllSeriesThumbnailsMutation.mutate(library.id); + }); + }; + + const handlePurgeAllDeleted = () => { + if (!libraries) return; + + libraries.forEach((library) => { + purgeMutation.mutate(library.id); + }); + }; + + // Handler for library auto-match action + const handleLibraryAutoMatch = ( + library: Library, + plugin: PluginActionDto, + ) => { + autoMatchMutation.mutate({ + libraryId: library.id, + pluginId: plugin.pluginId, + }); + }; + const handleEditLibrary = (library: Library) => { setSelectedLibrary(library); setEditLibraryOpened(true); @@ -248,8 +437,60 @@ export function Sidebar({ currentPath = "/" }: SidebarProps) { leftSection={} onClick={() => handleScanAll("deep")} > - Deep Scan All Libraries + Scan All Libraries (Deep) + {canWriteTasks && ( + <> + + Book Thumbnails + } + onClick={handleGenerateAllMissingThumbnails} + disabled={ + generateMissingThumbnailsMutation.isPending + } + > + Generate Missing + + } + onClick={handleRegenerateAllThumbnails} + disabled={ + regenerateAllThumbnailsMutation.isPending + } + > + Regenerate All + + + Series Thumbnails + } + onClick={handleGenerateAllMissingSeriesThumbnails} + disabled={ + generateMissingSeriesThumbnailsMutation.isPending + } + > + Generate Missing + + } + onClick={handleRegenerateAllSeriesThumbnails} + disabled={ + regenerateAllSeriesThumbnailsMutation.isPending + } + > + Regenerate All + + + } + color="orange" + onClick={handlePurgeAllDeleted} + > + Purge All Deleted Books + + + )} @@ -317,6 +558,105 @@ export function Sidebar({ currentPath = "/" }: SidebarProps) { > Edit Library + {canWriteTasks && ( + <> + + Book Thumbnails + } + onClick={() => + generateMissingThumbnailsMutation.mutate( + library.id, + ) + } + disabled={ + generateMissingThumbnailsMutation.isPending + } + > + Generate Missing + + } + onClick={() => + regenerateAllThumbnailsMutation.mutate( + library.id, + ) + } + disabled={ + regenerateAllThumbnailsMutation.isPending + } + > + Regenerate All + + + Series Thumbnails + } + onClick={() => + generateMissingSeriesThumbnailsMutation.mutate( + library.id, + ) + } + disabled={ + generateMissingSeriesThumbnailsMutation.isPending + } + > + Generate Missing + + } + onClick={() => + regenerateAllSeriesThumbnailsMutation.mutate( + library.id, + ) + } + disabled={ + regenerateAllSeriesThumbnailsMutation.isPending + } + > + Regenerate All + + + )} + {/* Plugin actions for library-wide auto-match */} + {(() => { + // Filter plugin actions to only show those that apply to this library + // Empty libraryIds means plugin applies to all libraries + const libraryPluginActions = + pluginActions?.actions.filter((action) => { + const libIds = action.libraryIds ?? []; + return ( + libIds.length === 0 || + libIds.includes(library.id) + ); + }) ?? []; + + return ( + libraryPluginActions.length > 0 && ( + <> + + + Auto-Apply Metadata + + {libraryPluginActions.map((action) => ( + } + onClick={() => + handleLibraryAutoMatch( + library, + action, + ) + } + disabled={autoMatchMutation.isPending} + > + {action.pluginDisplayName} + + ))} + + ) + ); + })()} } @@ -388,6 +728,13 @@ export function Sidebar({ currentPath = "/" }: SidebarProps) { leftSection={} active={currentPath.startsWith("/settings/metrics")} /> + } + active={currentPath.startsWith("/settings/plugins")} + /> {/* Access Section */} state.toggleSelection, + ); + const selectRange = useBulkSelectionStore((state) => state.selectRange); + const getLastSelectedIndex = useBulkSelectionStore( + (state) => state.getLastSelectedIndex, + ); + // Get the Set directly for O(1) lookups - only re-renders when the Set changes + const selectedIds = useBulkSelectionStore((state) => state.selectedIds); + + // Grid ID for range selection tracking + const gridId = `books-${libraryId}`; + + // Ref for storing books data for range selection (updated after query) + const booksDataRef = useRef([]); + // Get show deleted preference from user preferences store const showDeletedBooks = useUserPreferencesStore((state) => state.getPreference("library.show_deleted_books"), @@ -146,6 +171,40 @@ export function BooksSection({ } }, [booksData, onTotalChange]); + // Update booksDataRef when data changes (for range selection) + if (booksData?.data) { + booksDataRef.current = booksData.data; + } + + // Handle selection with shift+click range support + // This callback is stable because it uses refs for data that changes + const handleSelect = useCallback( + (id: string, shiftKey: boolean, index?: number) => { + if (shiftKey && isSelectionMode && index !== undefined) { + // Shift+click: select range from last selected to current + const lastIndex = getLastSelectedIndex(gridId); + if (lastIndex !== undefined && lastIndex !== index) { + const start = Math.min(lastIndex, index); + const end = Math.max(lastIndex, index); + const rangeIds = booksDataRef.current + .slice(start, end + 1) + .map((item) => item.id); + selectRange(rangeIds, "book"); + return; + } + } + // Normal click: toggle selection + toggleSelection(id, "book", gridId, index); + }, + [ + toggleSelection, + selectRange, + getLastSelectedIndex, + gridId, + isSelectionMode, + ], + ); + return ( {/* Active Filters Summary */} @@ -175,8 +234,17 @@ export function BooksSection({ width: "100%", }} > - {booksData.data.map((book) => ( - + {booksData.data.map((book, index) => ( + ))} diff --git a/web/src/components/library/BulkSelectionToolbar.test.tsx b/web/src/components/library/BulkSelectionToolbar.test.tsx new file mode 100644 index 00000000..922ee793 --- /dev/null +++ b/web/src/components/library/BulkSelectionToolbar.test.tsx @@ -0,0 +1,456 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useBulkSelectionStore } from "@/store/bulkSelectionStore"; +import { renderWithProviders, screen, userEvent, waitFor } from "@/test/utils"; +import { BulkSelectionToolbar } from "./BulkSelectionToolbar"; + +// Mock the API modules +vi.mock("@/api/books", () => ({ + booksApi: { + bulkMarkAsRead: vi + .fn() + .mockResolvedValue({ count: 2, message: "2 books marked as read" }), + bulkMarkAsUnread: vi + .fn() + .mockResolvedValue({ count: 2, message: "2 books marked as unread" }), + bulkAnalyze: vi.fn().mockResolvedValue({ + tasksEnqueued: 2, + message: "Enqueued 2 analysis tasks", + }), + }, +})); + +vi.mock("@/api/series", () => ({ + seriesApi: { + bulkMarkAsRead: vi.fn().mockResolvedValue({ + count: 5, + message: "5 books marked as read across 2 series", + }), + bulkMarkAsUnread: vi.fn().mockResolvedValue({ + count: 5, + message: "5 books marked as unread across 2 series", + }), + bulkAnalyze: vi.fn().mockResolvedValue({ + tasksEnqueued: 5, + message: "Enqueued 5 analysis tasks for 2 series", + }), + }, +})); + +// Mock the plugins API +const mockPluginActions = { + actions: [ + { + pluginId: "plugin-mangabaka", + pluginName: "mangabaka", + pluginDisplayName: "MangaBaka", + actionType: "metadata_search", + label: "Search MangaBaka", + description: "Fetches manga metadata from MangaUpdates", + icon: null, + }, + ], + scope: "series:bulk", +}; + +vi.mock("@/api/plugins", () => ({ + pluginsApi: { + getActions: vi.fn().mockImplementation((scope: string) => { + if (scope === "series:bulk") { + return Promise.resolve(mockPluginActions); + } + return Promise.resolve({ actions: [], scope }); + }), + }, + pluginActionsApi: { + enqueueBulkAutoMatchTasks: vi.fn().mockResolvedValue({ + success: true, + tasksEnqueued: 2, + taskIds: ["task-1", "task-2"], + message: "Enqueued 2 auto-match task(s)", + }), + }, +})); + +describe("BulkSelectionToolbar", () => { + beforeEach(() => { + // Reset the store state before each test + useBulkSelectionStore.getState().clearSelection(); + vi.clearAllMocks(); + }); + + describe("visibility", () => { + it("should not render when no items are selected", () => { + renderWithProviders(); + + // Toolbar should not be visible + expect(screen.queryByText(/selected/)).not.toBeInTheDocument(); + }); + + it("should render when books are selected", () => { + // Select a book + useBulkSelectionStore.getState().toggleSelection("book-1", "book"); + + renderWithProviders(); + + expect(screen.getByText("1 book selected")).toBeInTheDocument(); + }); + + it("should render when series are selected", () => { + // Select a series + useBulkSelectionStore.getState().toggleSelection("series-1", "series"); + + renderWithProviders(); + + expect(screen.getByText("1 series selected")).toBeInTheDocument(); + }); + + it("should show correct count for multiple books", () => { + useBulkSelectionStore.getState().toggleSelection("book-1", "book"); + useBulkSelectionStore.getState().toggleSelection("book-2", "book"); + useBulkSelectionStore.getState().toggleSelection("book-3", "book"); + + renderWithProviders(); + + expect(screen.getByText("3 books selected")).toBeInTheDocument(); + }); + + it("should show correct count for multiple series", () => { + useBulkSelectionStore.getState().toggleSelection("series-1", "series"); + useBulkSelectionStore.getState().toggleSelection("series-2", "series"); + + renderWithProviders(); + + expect(screen.getByText("2 series selected")).toBeInTheDocument(); + }); + }); + + describe("action buttons", () => { + it("should display Mark Read, Mark Unread, and Analyze buttons", () => { + useBulkSelectionStore.getState().toggleSelection("book-1", "book"); + + renderWithProviders(); + + expect( + screen.getByRole("button", { name: /mark read/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /mark unread/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /analyze/i }), + ).toBeInTheDocument(); + }); + + it("should display close button", () => { + useBulkSelectionStore.getState().toggleSelection("book-1", "book"); + + renderWithProviders(); + + expect( + screen.getByRole("button", { name: /clear selection/i }), + ).toBeInTheDocument(); + }); + }); + + describe("clear selection", () => { + it("should clear selection when close button is clicked", async () => { + const user = userEvent.setup(); + useBulkSelectionStore.getState().toggleSelection("book-1", "book"); + useBulkSelectionStore.getState().toggleSelection("book-2", "book"); + + const { rerender } = renderWithProviders(); + + expect(screen.getByText("2 books selected")).toBeInTheDocument(); + + await user.click( + screen.getByRole("button", { name: /clear selection/i }), + ); + + rerender(); + + // After clearing, toolbar should not render + expect(screen.queryByText(/selected/)).not.toBeInTheDocument(); + expect(useBulkSelectionStore.getState().selectedIds.size).toBe(0); + }); + + it("should clear selection when Escape key is pressed", async () => { + const user = userEvent.setup(); + useBulkSelectionStore.getState().toggleSelection("book-1", "book"); + + const { rerender } = renderWithProviders(); + + expect(screen.getByText("1 book selected")).toBeInTheDocument(); + + await user.keyboard("{Escape}"); + + rerender(); + + expect(screen.queryByText(/selected/)).not.toBeInTheDocument(); + expect(useBulkSelectionStore.getState().selectedIds.size).toBe(0); + }); + }); + + describe("book bulk actions", () => { + it("should call bulkMarkAsRead when Mark Read is clicked for books", async () => { + const { booksApi } = await import("@/api/books"); + const user = userEvent.setup(); + + useBulkSelectionStore.getState().toggleSelection("book-1", "book"); + useBulkSelectionStore.getState().toggleSelection("book-2", "book"); + + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /mark read/i })); + + await waitFor(() => { + expect(booksApi.bulkMarkAsRead).toHaveBeenCalledWith([ + "book-1", + "book-2", + ]); + }); + }); + + it("should call bulkMarkAsUnread when Mark Unread is clicked for books", async () => { + const { booksApi } = await import("@/api/books"); + const user = userEvent.setup(); + + useBulkSelectionStore.getState().toggleSelection("book-1", "book"); + + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /mark unread/i })); + + await waitFor(() => { + expect(booksApi.bulkMarkAsUnread).toHaveBeenCalledWith(["book-1"]); + }); + }); + + it("should call bulkAnalyze when Analyze is clicked for books", async () => { + const { booksApi } = await import("@/api/books"); + const user = userEvent.setup(); + + useBulkSelectionStore.getState().toggleSelection("book-1", "book"); + useBulkSelectionStore.getState().toggleSelection("book-2", "book"); + + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /analyze/i })); + + await waitFor(() => { + expect(booksApi.bulkAnalyze).toHaveBeenCalledWith(["book-1", "book-2"]); + }); + }); + }); + + describe("series bulk actions", () => { + it("should call bulkMarkAsRead when Mark Read is clicked for series", async () => { + const { seriesApi } = await import("@/api/series"); + const user = userEvent.setup(); + + useBulkSelectionStore.getState().toggleSelection("series-1", "series"); + useBulkSelectionStore.getState().toggleSelection("series-2", "series"); + + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /mark read/i })); + + await waitFor(() => { + expect(seriesApi.bulkMarkAsRead).toHaveBeenCalledWith([ + "series-1", + "series-2", + ]); + }); + }); + + it("should call bulkMarkAsUnread when Mark Unread is clicked for series", async () => { + const { seriesApi } = await import("@/api/series"); + const user = userEvent.setup(); + + useBulkSelectionStore.getState().toggleSelection("series-1", "series"); + + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /mark unread/i })); + + await waitFor(() => { + expect(seriesApi.bulkMarkAsUnread).toHaveBeenCalledWith(["series-1"]); + }); + }); + + it("should call bulkAnalyze when Analyze is clicked for series", async () => { + const { seriesApi } = await import("@/api/series"); + const user = userEvent.setup(); + + useBulkSelectionStore.getState().toggleSelection("series-1", "series"); + + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /analyze/i })); + + await waitFor(() => { + expect(seriesApi.bulkAnalyze).toHaveBeenCalledWith(["series-1"]); + }); + }); + }); + + describe("selection clearing after action", () => { + it("should clear selection after successful Mark Read action", async () => { + const user = userEvent.setup(); + useBulkSelectionStore.getState().toggleSelection("book-1", "book"); + + const { rerender } = renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /mark read/i })); + + await waitFor(() => { + expect(useBulkSelectionStore.getState().selectedIds.size).toBe(0); + }); + + rerender(); + expect(screen.queryByText(/selected/)).not.toBeInTheDocument(); + }); + + it("should clear selection after successful Mark Unread action", async () => { + const user = userEvent.setup(); + useBulkSelectionStore.getState().toggleSelection("book-1", "book"); + + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /mark unread/i })); + + await waitFor(() => { + expect(useBulkSelectionStore.getState().selectedIds.size).toBe(0); + }); + }); + + it("should clear selection after successful Analyze action", async () => { + const user = userEvent.setup(); + useBulkSelectionStore.getState().toggleSelection("book-1", "book"); + + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /analyze/i })); + + await waitFor(() => { + expect(useBulkSelectionStore.getState().selectedIds.size).toBe(0); + }); + }); + }); + + describe("plugin actions", () => { + it("should display Plugins button when series are selected and plugins are available", async () => { + useBulkSelectionStore.getState().toggleSelection("series-1", "series"); + useBulkSelectionStore.getState().toggleSelection("series-2", "series"); + + renderWithProviders(); + + // Wait for the plugin actions query to complete + await waitFor(() => { + expect( + screen.getByRole("button", { name: /plugin actions/i }), + ).toBeInTheDocument(); + }); + }); + + it("should not display Plugins button when books are selected", async () => { + useBulkSelectionStore.getState().toggleSelection("book-1", "book"); + + renderWithProviders(); + + // Wait a bit to ensure query would have completed + await waitFor(() => { + expect(screen.getByText("1 book selected")).toBeInTheDocument(); + }); + + // Plugins button should not be present for books (no book:bulk plugins available) + expect( + screen.queryByRole("button", { name: /plugin actions/i }), + ).not.toBeInTheDocument(); + }); + + it("should show plugin menu items when Plugins button is clicked", async () => { + const user = userEvent.setup(); + useBulkSelectionStore.getState().toggleSelection("series-1", "series"); + + renderWithProviders(); + + // Wait for the plugin actions query to complete + await waitFor(() => { + expect( + screen.getByRole("button", { name: /plugin actions/i }), + ).toBeInTheDocument(); + }); + + // Click the Plugins button to open the menu + await user.click(screen.getByRole("button", { name: /plugin actions/i })); + + // Check that the menu item is visible + await waitFor(() => { + expect(screen.getByText("MangaBaka")).toBeInTheDocument(); + }); + }); + + it("should call enqueueBulkAutoMatchTasks when a plugin action is clicked", async () => { + const { pluginActionsApi } = await import("@/api/plugins"); + const user = userEvent.setup(); + + useBulkSelectionStore.getState().toggleSelection("series-1", "series"); + useBulkSelectionStore.getState().toggleSelection("series-2", "series"); + + renderWithProviders(); + + // Wait for the plugin actions query to complete + await waitFor(() => { + expect( + screen.getByRole("button", { name: /plugin actions/i }), + ).toBeInTheDocument(); + }); + + // Click the Plugins button to open the menu + await user.click(screen.getByRole("button", { name: /plugin actions/i })); + + // Wait for menu to appear and click the plugin action + await waitFor(() => { + expect(screen.getByText("MangaBaka")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("MangaBaka")); + + await waitFor(() => { + expect(pluginActionsApi.enqueueBulkAutoMatchTasks).toHaveBeenCalledWith( + "plugin-mangabaka", + ["series-1", "series-2"], + ); + }); + }); + + it("should clear selection after successful plugin action", async () => { + const user = userEvent.setup(); + useBulkSelectionStore.getState().toggleSelection("series-1", "series"); + + const { rerender } = renderWithProviders(); + + // Wait for the plugin actions query to complete + await waitFor(() => { + expect( + screen.getByRole("button", { name: /plugin actions/i }), + ).toBeInTheDocument(); + }); + + // Click the Plugins button and select an action + await user.click(screen.getByRole("button", { name: /plugin actions/i })); + await waitFor(() => { + expect(screen.getByText("MangaBaka")).toBeInTheDocument(); + }); + await user.click(screen.getByText("MangaBaka")); + + // Wait for the selection to be cleared + await waitFor(() => { + expect(useBulkSelectionStore.getState().selectedIds.size).toBe(0); + }); + + rerender(); + expect(screen.queryByText(/selected/)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/web/src/components/library/BulkSelectionToolbar.tsx b/web/src/components/library/BulkSelectionToolbar.tsx new file mode 100644 index 00000000..3bdf672e --- /dev/null +++ b/web/src/components/library/BulkSelectionToolbar.tsx @@ -0,0 +1,444 @@ +import { + ActionIcon, + Button, + Group, + Loader, + Menu, + Text, + Tooltip, +} from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import { + IconAnalyze, + IconBook, + IconBookOff, + IconChevronDown, + IconWand, + IconX, +} from "@tabler/icons-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo } from "react"; +import { booksApi } from "@/api/books"; +import { pluginActionsApi, pluginsApi } from "@/api/plugins"; +import { seriesApi } from "@/api/series"; +import { + selectSelectionCount, + selectSelectionType, + useBulkSelectionStore, +} from "@/store/bulkSelectionStore"; + +/** + * BulkSelectionToolbar - Fixed header toolbar that appears when items are selected + * + * Shows: + * - X button to clear selection + * - Count of selected items + * - Action buttons: Mark Read, Mark Unread, Analyze + * - Plugin actions dropdown for series:bulk scope + * + * Uses bulk API endpoints for efficient batch operations. + */ +export function BulkSelectionToolbar() { + const queryClient = useQueryClient(); + + // Selection state + const count = useBulkSelectionStore(selectSelectionCount); + const selectionType = useBulkSelectionStore(selectSelectionType); + // Get the Set directly and convert to array with useMemo for stable reference + const selectedIdsSet = useBulkSelectionStore((state) => state.selectedIds); + const selectedIds = useMemo( + () => Array.from(selectedIdsSet), + [selectedIdsSet], + ); + const clearSelection = useBulkSelectionStore((state) => state.clearSelection); + + // Fetch plugin actions for series:bulk scope (only when series are selected) + const { data: seriesPluginActions } = useQuery({ + queryKey: ["plugin-actions", "series:bulk"], + queryFn: () => pluginsApi.getActions("series:bulk"), + staleTime: 5 * 60 * 1000, // Cache for 5 minutes + enabled: selectionType === "series" && count > 0, + }); + + // Placeholder for future book:bulk plugin actions + // Currently disabled as the backend doesn't support this scope yet + // When book:bulk plugins are added to the backend, enable this query: + // const { data: bookPluginActions } = useQuery({ + // queryKey: ["plugin-actions", "book:bulk"], + // queryFn: () => pluginsApi.getActions("book:bulk"), + // staleTime: 5 * 60 * 1000, + // enabled: selectionType === "book" && count > 0, + // }); + const bookPluginActions = { actions: [] }; // Empty until backend supports book:bulk + + // Helper to refetch all related queries + const refetchAll = () => { + queryClient.refetchQueries({ + predicate: (query) => { + const key = query.queryKey[0] as string; + return ( + key === "books" || + key === "series" || + key === "series-books" || + key === "book-detail" + ); + }, + }); + }; + + // Bulk mark books as read + const bulkMarkBooksReadMutation = useMutation({ + mutationFn: (bookIds: string[]) => booksApi.bulkMarkAsRead(bookIds), + onSuccess: (data) => { + notifications.show({ + title: "Marked as read", + message: data.message, + color: "green", + }); + refetchAll(); + clearSelection(); + }, + onError: (error: Error) => { + notifications.show({ + title: "Failed to mark as read", + message: error.message || "Failed to mark books as read", + color: "red", + }); + }, + }); + + // Bulk mark books as unread + const bulkMarkBooksUnreadMutation = useMutation({ + mutationFn: (bookIds: string[]) => booksApi.bulkMarkAsUnread(bookIds), + onSuccess: (data) => { + notifications.show({ + title: "Marked as unread", + message: data.message, + color: "blue", + }); + refetchAll(); + clearSelection(); + }, + onError: (error: Error) => { + notifications.show({ + title: "Failed to mark as unread", + message: error.message || "Failed to mark books as unread", + color: "red", + }); + }, + }); + + // Bulk analyze books + const bulkAnalyzeBooksMutation = useMutation({ + mutationFn: (bookIds: string[]) => booksApi.bulkAnalyze(bookIds), + onSuccess: (data) => { + notifications.show({ + title: "Analysis started", + message: data.message, + color: "blue", + }); + refetchAll(); + clearSelection(); + }, + onError: (error: Error) => { + notifications.show({ + title: "Failed to start analysis", + message: error.message || "Failed to queue book analysis", + color: "red", + }); + }, + }); + + // Bulk mark series as read + const bulkMarkSeriesReadMutation = useMutation({ + mutationFn: (seriesIds: string[]) => seriesApi.bulkMarkAsRead(seriesIds), + onSuccess: (data) => { + notifications.show({ + title: "Marked as read", + message: data.message, + color: "green", + }); + refetchAll(); + clearSelection(); + }, + onError: (error: Error) => { + notifications.show({ + title: "Failed to mark as read", + message: error.message || "Failed to mark series as read", + color: "red", + }); + }, + }); + + // Bulk mark series as unread + const bulkMarkSeriesUnreadMutation = useMutation({ + mutationFn: (seriesIds: string[]) => seriesApi.bulkMarkAsUnread(seriesIds), + onSuccess: (data) => { + notifications.show({ + title: "Marked as unread", + message: data.message, + color: "blue", + }); + refetchAll(); + clearSelection(); + }, + onError: (error: Error) => { + notifications.show({ + title: "Failed to mark as unread", + message: error.message || "Failed to mark series as unread", + color: "red", + }); + }, + }); + + // Bulk analyze series + const bulkAnalyzeSeriesMutation = useMutation({ + mutationFn: (seriesIds: string[]) => seriesApi.bulkAnalyze(seriesIds), + onSuccess: (data) => { + notifications.show({ + title: "Analysis started", + message: data.message, + color: "blue", + }); + refetchAll(); + clearSelection(); + }, + onError: (error: Error) => { + notifications.show({ + title: "Failed to start analysis", + message: error.message || "Failed to queue series analysis", + color: "red", + }); + }, + }); + + // Bulk auto-match series metadata using a plugin + const bulkAutoMatchMutation = useMutation({ + mutationFn: ({ + pluginId, + seriesIds, + }: { + pluginId: string; + seriesIds: string[]; + }) => pluginActionsApi.enqueueBulkAutoMatchTasks(pluginId, seriesIds), + onSuccess: (data) => { + if (data.success) { + notifications.show({ + title: "Auto-match started", + message: data.message, + color: "blue", + }); + } else { + notifications.show({ + title: "Auto-match", + message: data.message, + color: "yellow", + }); + } + refetchAll(); + clearSelection(); + }, + onError: (error: Error) => { + notifications.show({ + title: "Auto-match failed", + message: error.message || "Failed to start auto-match", + color: "red", + }); + }, + }); + + // Keyboard shortcut: Escape to clear selection + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && count > 0) { + clearSelection(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [count, clearSelection]); + + // Don't render if nothing is selected + if (count === 0) { + return null; + } + + // Determine which mutations to use based on selection type + const isBooks = selectionType === "book"; + const markReadMutation = isBooks + ? bulkMarkBooksReadMutation + : bulkMarkSeriesReadMutation; + const markUnreadMutation = isBooks + ? bulkMarkBooksUnreadMutation + : bulkMarkSeriesUnreadMutation; + const analyzeMutation = isBooks + ? bulkAnalyzeBooksMutation + : bulkAnalyzeSeriesMutation; + + const isAnyPending = + markReadMutation.isPending || + markUnreadMutation.isPending || + analyzeMutation.isPending || + bulkAutoMatchMutation.isPending; + + // Get available plugin actions based on selection type + const pluginActions = isBooks + ? (bookPluginActions?.actions ?? []) + : (seriesPluginActions?.actions ?? []); + const hasPluginActions = pluginActions.length > 0; + + // Handle plugin auto-match action + // Note: Currently only series bulk auto-match is supported + // Book bulk actions will need a different API when plugins support it + const handlePluginAutoMatch = (pluginId: string) => { + if (!isBooks) { + bulkAutoMatchMutation.mutate({ pluginId, seriesIds: selectedIds }); + } + // Future: Add book bulk plugin action support here + }; + + const handleMarkRead = () => { + if (isBooks) { + bulkMarkBooksReadMutation.mutate(selectedIds); + } else { + bulkMarkSeriesReadMutation.mutate(selectedIds); + } + }; + + const handleMarkUnread = () => { + if (isBooks) { + bulkMarkBooksUnreadMutation.mutate(selectedIds); + } else { + bulkMarkSeriesUnreadMutation.mutate(selectedIds); + } + }; + + const handleAnalyze = () => { + if (isBooks) { + bulkAnalyzeBooksMutation.mutate(selectedIds); + } else { + bulkAnalyzeSeriesMutation.mutate(selectedIds); + } + }; + + const itemLabel = isBooks + ? count === 1 + ? "book" + : "books" + : count === 1 + ? "series" + : "series"; + + return ( + + {/* Close button */} + + + + + + + {/* Selection count - announced to screen readers */} + + {count} {itemLabel} selected + + + {/* Action buttons */} + + {isAnyPending && } + + + + + + + + + + + + + + {/* Plugin actions menu (only for series) */} + {hasPluginActions && ( + + + + + + + + + Auto-Apply Metadata + {pluginActions.map((action) => ( + } + onClick={() => handlePluginAutoMatch(action.pluginId)} + disabled={isAnyPending} + > + {action.pluginDisplayName} + + ))} + + + )} + + + ); +} diff --git a/web/src/components/library/MediaCard.test.tsx b/web/src/components/library/MediaCard.test.tsx index 123d1ecf..6c7d9455 100644 --- a/web/src/components/library/MediaCard.test.tsx +++ b/web/src/components/library/MediaCard.test.tsx @@ -188,4 +188,266 @@ describe("MediaCard", () => { expect(progressBar).not.toBeInTheDocument(); }); }); + + describe("selection functionality", () => { + beforeEach(() => { + mockNavigate.mockClear(); + }); + + it("should not show checkbox when onSelect is not provided", () => { + const book = createBook(); + + renderWithProviders(); + + // Checkbox should not be present + expect( + screen.queryByRole("checkbox", { name: /select/i }), + ).not.toBeInTheDocument(); + }); + + it("should show checkbox when onSelect is provided", () => { + const book = createBook({ title: "Test Book" }); + const onSelect = vi.fn(); + + renderWithProviders( + , + ); + + // Checkbox should be present + expect( + screen.getByRole("checkbox", { name: /select test book/i }), + ).toBeInTheDocument(); + }); + + it("should call onSelect when checkbox is clicked", async () => { + const user = userEvent.setup(); + const book = createBook({ id: "book-123", title: "Test Book" }); + const onSelect = vi.fn(); + + renderWithProviders( + , + ); + + const checkbox = screen.getByRole("checkbox", { + name: /select test book/i, + }); + await user.click(checkbox); + + expect(onSelect).toHaveBeenCalledWith("book-123", false, undefined); + }); + + it("should show checkbox as checked when isSelected is true", () => { + const book = createBook({ title: "Test Book" }); + const onSelect = vi.fn(); + + renderWithProviders( + , + ); + + const checkbox = screen.getByRole("checkbox", { + name: /select test book/i, + }); + expect(checkbox).toBeChecked(); + }); + + it("should show checkbox as unchecked when isSelected is false", () => { + const book = createBook({ title: "Test Book" }); + const onSelect = vi.fn(); + + renderWithProviders( + , + ); + + const checkbox = screen.getByRole("checkbox", { + name: /select test book/i, + }); + expect(checkbox).not.toBeChecked(); + }); + + it("should disable checkbox when canBeSelected is false", () => { + const book = createBook({ title: "Test Book" }); + const onSelect = vi.fn(); + + renderWithProviders( + , + ); + + const checkbox = screen.getByRole("checkbox", { + name: /select test book/i, + }); + expect(checkbox).toBeDisabled(); + }); + + it("should not call onSelect when checkbox is clicked and canBeSelected is false", async () => { + const user = userEvent.setup(); + const book = createBook({ title: "Test Book" }); + const onSelect = vi.fn(); + + renderWithProviders( + , + ); + + const checkbox = screen.getByRole("checkbox", { + name: /select test book/i, + }); + await user.click(checkbox); + + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("should navigate to book when card is clicked and not in selection mode", async () => { + const user = userEvent.setup(); + const book = createBook({ id: "book-123", title: "Navigate Test Book" }); + const onSelect = vi.fn(); + + renderWithProviders( + , + ); + + // Click on the card itself + const card = document.querySelector(".mantine-Card-root"); + if (card) { + await user.click(card); + } + + expect(mockNavigate).toHaveBeenCalledWith("/books/book-123"); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("should call onSelect when card is clicked in selection mode", async () => { + const user = userEvent.setup(); + const book = createBook({ + id: "book-123", + title: "Selection Mode Click Test", + }); + const onSelect = vi.fn(); + + renderWithProviders( + , + ); + + // Click on the card itself (via the Card element) + const card = document.querySelector(".mantine-Card-root"); + if (card) { + await user.click(card); + } + + expect(onSelect).toHaveBeenCalledWith("book-123", false, undefined); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it("should not call onSelect when card is clicked in selection mode but canBeSelected is false", async () => { + const user = userEvent.setup(); + const book = createBook({ + id: "book-123", + title: "Disabled Selection Test", + }); + const onSelect = vi.fn(); + + renderWithProviders( + , + ); + + // Click on the card itself + const card = document.querySelector(".mantine-Card-root"); + if (card) { + await user.click(card); + } + + expect(onSelect).not.toHaveBeenCalled(); + // Should also not navigate when canBeSelected is false + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it("should have orange border when selected", () => { + const book = createBook(); + const onSelect = vi.fn(); + + renderWithProviders( + , + ); + + const card = document.querySelector(".mantine-Card-root"); + expect(card).toHaveStyle({ + border: "3px solid var(--mantine-color-orange-6)", + }); + }); + + it("should apply selection mode class when isSelectionMode is true", () => { + const book = createBook(); + const onSelect = vi.fn(); + + renderWithProviders( + , + ); + + const card = document.querySelector(".mantine-Card-root"); + expect(card).toHaveClass("media-card--selection-mode"); + }); + + it("should apply disabled class when in selection mode and canBeSelected is false", () => { + const book = createBook(); + const onSelect = vi.fn(); + + renderWithProviders( + , + ); + + const card = document.querySelector(".mantine-Card-root"); + expect(card).toHaveClass("media-card--disabled"); + }); + }); }); diff --git a/web/src/components/library/MediaCard.tsx b/web/src/components/library/MediaCard.tsx index 2d334973..4da0d09c 100644 --- a/web/src/components/library/MediaCard.tsx +++ b/web/src/components/library/MediaCard.tsx @@ -1,6 +1,7 @@ import { ActionIcon, Card, + Checkbox, Group, Image, Menu, @@ -20,7 +21,7 @@ import { IconTrash, } from "@tabler/icons-react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useState } from "react"; +import { memo, useCallback, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { booksApi } from "@/api/books"; import { seriesApi } from "@/api/series"; @@ -34,12 +35,27 @@ interface MediaCardProps { type: "book" | "series"; data: Book | Series; hideSeriesName?: boolean; + /** Callback when item is selected/deselected. Receives id, shiftKey, and optional index. */ + onSelect?: (id: string, shiftKey: boolean, index?: number) => void; + /** Whether this item is currently selected */ + isSelected?: boolean; + /** Whether bulk selection mode is active (at least one item selected) */ + isSelectionMode?: boolean; + /** Whether this item can be selected (type matches current selection) */ + canBeSelected?: boolean; + /** Index of this item in the grid (for range selection) */ + index?: number; } -export function MediaCard({ +export const MediaCard = memo(function MediaCard({ type, data, hideSeriesName = false, + onSelect, + isSelected = false, + isSelectionMode = false, + canBeSelected = true, + index, }: MediaCardProps) { const queryClient = useQueryClient(); const navigate = useNavigate(); @@ -52,11 +68,23 @@ export function MediaCard({ (state) => state.updates[data.id], ); - // Handle card click navigation + // Handle card click navigation or selection const handleCardClick = (e: React.MouseEvent) => { - // Don't navigate if clicking the menu button or dropdown + // Don't navigate if clicking the menu button, dropdown, or checkbox if ((e.target as HTMLElement).closest("[data-menu]")) return; + if ((e.target as HTMLElement).closest("[data-selection-checkbox]")) return; + // In selection mode, clicking the card toggles selection (if allowed) + // or does nothing (if type mismatch) + if (isSelectionMode && onSelect) { + if (canBeSelected) { + onSelect(data.id, e.shiftKey, index); + } + // In selection mode, don't navigate regardless of canBeSelected + return; + } + + // Normal navigation (only when not in selection mode) if (type === "series") { navigate(`/series/${(data as Series).id}`); } else { @@ -339,6 +367,15 @@ export function MediaCard({ : series?.title || ""; const altText = book ? book.title : series?.title || ""; + // Build class names for selection state + const cardClassNames = [ + isSelectionMode && "media-card--selection-mode", + isSelected && "media-card--selected", + isSelectionMode && !canBeSelected && "media-card--disabled", + ] + .filter(Boolean) + .join(" "); + return ( @@ -423,6 +465,38 @@ export function MediaCard({ /> )} + {/* Selection checkbox - top left */} + {onSelect && ( +
+ { + // Prevent event from bubbling to card click handler + e.stopPropagation(); + if (canBeSelected && onSelect) { + // Get the native event to check for shift key + const nativeEvent = e.nativeEvent as unknown as MouseEvent; + onSelect(data.id, nativeEvent?.shiftKey ?? false, index); + } + }} + disabled={!canBeSelected} + color="orange" + size="md" + aria-label={`Select ${type === "book" ? book?.title : series?.title}`} + styles={{ + input: { + cursor: canBeSelected ? "pointer" : "not-allowed", + backgroundColor: isSelected + ? undefined + : "rgba(255, 255, 255, 0.9)", + }, + }} + /> +
+ )} {/* Unread indicator - Triangle for books, Square for series */} {type === "book" && book && !book.readProgress && (
); -} +}); diff --git a/web/src/components/library/RecommendedSection.tsx b/web/src/components/library/RecommendedSection.tsx index cd418dd2..f9147115 100644 --- a/web/src/components/library/RecommendedSection.tsx +++ b/web/src/components/library/RecommendedSection.tsx @@ -1,10 +1,16 @@ import { Card, Stack, Text } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; +import { useCallback } from "react"; import { booksApi } from "@/api/books"; import { seriesApi } from "@/api/series"; import { HorizontalCarousel } from "@/components/library/HorizontalCarousel"; import { MediaCard } from "@/components/library/MediaCard"; +import { + selectCanSelectType, + selectIsSelectionMode, + useBulkSelectionStore, +} from "@/store/bulkSelectionStore"; const MAX_ITEMS_PER_SECTION = 20; @@ -13,6 +19,34 @@ interface RecommendedSectionProps { } export function RecommendedSection({ libraryId }: RecommendedSectionProps) { + // Bulk selection state - use stable selectors to minimize re-renders + const isSelectionMode = useBulkSelectionStore(selectIsSelectionMode); + const canSelectBooks = useBulkSelectionStore(selectCanSelectType("book")); + const canSelectSeries = useBulkSelectionStore(selectCanSelectType("series")); + const toggleSelection = useBulkSelectionStore( + (state) => state.toggleSelection, + ); + // Get the Set directly for O(1) lookups - only re-renders when the Set changes + const selectedIds = useBulkSelectionStore((state) => state.selectedIds); + + // Handle selection for books + const handleBookSelect = useCallback( + (id: string, _shiftKey: boolean) => { + // TODO: Implement shift+click range selection + toggleSelection(id, "book"); + }, + [toggleSelection], + ); + + // Handle selection for series + const handleSeriesSelect = useCallback( + (id: string, _shiftKey: boolean) => { + // TODO: Implement shift+click range selection + toggleSelection(id, "series"); + }, + [toggleSelection], + ); + // Fetch books with reading progress (Keep Reading) const { data: inProgressBooks, isLoading: loadingInProgress } = useQuery({ queryKey: ["books", "in-progress", libraryId], @@ -91,7 +125,15 @@ export function RecommendedSection({ libraryId }: RecommendedSectionProps) { {limitedInProgressBooks.length > 0 && ( {limitedInProgressBooks.map((book) => ( - + ))} )} @@ -103,7 +145,15 @@ export function RecommendedSection({ libraryId }: RecommendedSectionProps) { subtitle="Next book in series you've been reading" > {onDeckBooks.map((book) => ( - + ))} )} @@ -112,7 +162,15 @@ export function RecommendedSection({ libraryId }: RecommendedSectionProps) { {limitedRecentlyAddedBooks.length > 0 && ( {limitedRecentlyAddedBooks.map((book) => ( - + ))} )} @@ -121,7 +179,15 @@ export function RecommendedSection({ libraryId }: RecommendedSectionProps) { {recentlyAddedSeries && recentlyAddedSeries.length > 0 && ( {recentlyAddedSeries.map((series) => ( - + ))} )} @@ -133,7 +199,15 @@ export function RecommendedSection({ libraryId }: RecommendedSectionProps) { subtitle="Series with new or updated content" > {recentlyUpdatedSeries.map((series) => ( - + ))} )} @@ -145,7 +219,15 @@ export function RecommendedSection({ libraryId }: RecommendedSectionProps) { subtitle="Books you've read recently" > {recentlyReadBooks.map((book) => ( - + ))} )} diff --git a/web/src/components/library/SeriesSection.tsx b/web/src/components/library/SeriesSection.tsx index f6fb16b9..2bb4a7d8 100644 --- a/web/src/components/library/SeriesSection.tsx +++ b/web/src/components/library/SeriesSection.tsx @@ -8,7 +8,7 @@ import { Text, } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { seriesApi } from "@/api/series"; import { ActiveFilters } from "@/components/library/ActiveFilters"; @@ -19,6 +19,11 @@ import { } from "@/components/library/AlphabetFilter"; import { MediaCard } from "@/components/library/MediaCard"; import { useSeriesFilterState } from "@/hooks/useSeriesFilterState"; +import { + selectCanSelectType, + selectIsSelectionMode, + useBulkSelectionStore, +} from "@/store/bulkSelectionStore"; import type { SeriesCondition } from "@/types"; /** Fixed skeleton IDs to avoid array index keys */ @@ -73,6 +78,25 @@ export function SeriesSection({ const navigate = useNavigate(); const [, setSearchParams] = useSearchParams(); + // Bulk selection state - use stable selectors to minimize re-renders + const isSelectionMode = useBulkSelectionStore(selectIsSelectionMode); + const canSelectSeries = useBulkSelectionStore(selectCanSelectType("series")); + const toggleSelection = useBulkSelectionStore( + (state) => state.toggleSelection, + ); + const selectRange = useBulkSelectionStore((state) => state.selectRange); + const getLastSelectedIndex = useBulkSelectionStore( + (state) => state.getLastSelectedIndex, + ); + // Get the Set directly for O(1) lookups - only re-renders when the Set changes + const selectedIds = useBulkSelectionStore((state) => state.selectedIds); + + // Grid ID for range selection tracking + const gridId = `series-${libraryId}`; + + // Ref for storing series data for range selection (updated after query) + const seriesDataRef = useRef<{ id: string }[]>([]); + // Get filter state from URL (uses the advanced filtering system) // Filters are only applied when user clicks "Apply" in FilterPanel, // so no debouncing is needed here @@ -217,6 +241,40 @@ export function SeriesSection({ } }, [seriesData, onTotalChange]); + // Update seriesDataRef when data changes (for range selection) + if (seriesData?.data) { + seriesDataRef.current = seriesData.data; + } + + // Handle selection with shift+click range support + // This callback is stable because it uses refs for data that changes + const handleSelect = useCallback( + (id: string, shiftKey: boolean, index?: number) => { + if (shiftKey && isSelectionMode && index !== undefined) { + // Shift+click: select range from last selected to current + const lastIndex = getLastSelectedIndex(gridId); + if (lastIndex !== undefined && lastIndex !== index) { + const start = Math.min(lastIndex, index); + const end = Math.max(lastIndex, index); + const rangeIds = seriesDataRef.current + .slice(start, end + 1) + .map((item) => item.id); + selectRange(rangeIds, "series"); + return; + } + } + // Normal click: toggle selection + toggleSelection(id, "series", gridId, index); + }, + [ + toggleSelection, + selectRange, + getLastSelectedIndex, + gridId, + isSelectionMode, + ], + ); + // Handle alphabet filter selection const handleLetterSelect = useCallback( (letter: AlphabetLetter | null) => { @@ -273,8 +331,17 @@ export function SeriesSection({ width: "100%", }} > - {seriesData.data.map((series) => ( - + {seriesData.data.map((series, index) => ( + ))}
diff --git a/web/src/components/metadata/MetadataApplyFlow.test.tsx b/web/src/components/metadata/MetadataApplyFlow.test.tsx new file mode 100644 index 00000000..df027ba3 --- /dev/null +++ b/web/src/components/metadata/MetadataApplyFlow.test.tsx @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginActionDto } from "@/api/plugins"; +import { renderWithProviders, screen, waitFor } from "@/test/utils"; +import { MetadataApplyFlow } from "./MetadataApplyFlow"; + +// Mock the child components and API +vi.mock("@/api/plugins", () => ({ + pluginsApi: { + searchMetadata: vi.fn(), + }, + pluginActionsApi: { + previewSeriesMetadata: vi.fn(), + applySeriesMetadata: vi.fn(), + }, +})); + +import { pluginActionsApi, pluginsApi } from "@/api/plugins"; + +const mockPlugin: PluginActionDto = { + pluginId: "test-plugin-id", + pluginName: "test-plugin", + pluginDisplayName: "Test Plugin", + actionType: "metadata_search", + label: "Search Test Plugin", +}; + +const mockSearchResults = { + success: true, + result: { + results: [ + { + externalId: "ext-1", + title: "Test Series", + alternateTitles: [], + year: 2024, + coverUrl: null, + relevanceScore: 0.95, + preview: null, + }, + ], + }, + latencyMs: 150, +}; + +const mockPreviewResponse = { + fields: [ + { + field: "title", + currentValue: "Old Title", + proposedValue: "New Title", + status: "will_apply", + }, + ], + summary: { + willApply: 1, + locked: 0, + noPermission: 0, + unchanged: 0, + notProvided: 0, + }, + pluginId: "test-plugin-id", + pluginName: "Test Plugin", + externalId: "ext-1", +}; + +describe("MetadataApplyFlow", () => { + const onClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + (pluginsApi.searchMetadata as ReturnType).mockResolvedValue( + mockSearchResults, + ); + ( + pluginActionsApi.previewSeriesMetadata as ReturnType + ).mockResolvedValue(mockPreviewResponse); + ( + pluginActionsApi.applySeriesMetadata as ReturnType + ).mockResolvedValue({ + success: true, + appliedFields: ["title"], + skippedFields: [], + message: "Applied 1 field", + }); + }); + + it("starts in search step showing search modal", () => { + renderWithProviders( + , + ); + + // Should show search modal with plugin name + expect(screen.getByText("Search Test Plugin")).toBeInTheDocument(); + }); + + it("pre-fills search with entity title", () => { + renderWithProviders( + , + ); + + expect(screen.getByDisplayValue("My Series")).toBeInTheDocument(); + }); + + it("does not render when closed", () => { + renderWithProviders( + , + ); + + expect(screen.queryByText("Search Test Plugin")).not.toBeInTheDocument(); + }); + + it("uses series content type by default", async () => { + renderWithProviders( + , + ); + + // Trigger a search by waiting for debounce + await waitFor( + () => { + expect(pluginsApi.searchMetadata).toHaveBeenCalledWith( + "test-plugin-id", + "My Series", + "series", + ); + }, + { timeout: 1000 }, + ); + }); + + // TODO: Add test for "book" content type when it's supported by the API +}); diff --git a/web/src/components/metadata/MetadataApplyFlow.tsx b/web/src/components/metadata/MetadataApplyFlow.tsx new file mode 100644 index 00000000..4745cbd5 --- /dev/null +++ b/web/src/components/metadata/MetadataApplyFlow.tsx @@ -0,0 +1,172 @@ +import { Center, Group, Modal, Stack, Text, ThemeIcon } from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import { IconCheck, IconSearch } from "@tabler/icons-react"; +import { useEffect, useState } from "react"; +import type { PluginActionDto, PluginSearchResultDto } from "@/api/plugins"; +import { MetadataPreview } from "./MetadataPreview"; +import { MetadataSearchModal } from "./MetadataSearchModal"; + +export interface MetadataApplyFlowProps { + /** Whether the flow is active */ + opened: boolean; + /** Callback to close the flow */ + onClose: () => void; + /** The plugin to use */ + plugin: PluginActionDto; + /** Entity ID (series or book) */ + entityId: string; + /** Entity title for search */ + entityTitle: string; + /** Content type (only "series" is currently supported) */ + contentType?: "series"; + /** Callback when metadata is successfully applied */ + onApplySuccess?: () => void; +} + +type FlowStep = "search" | "preview" | "success"; + +/** + * Orchestrates the full metadata apply flow: + * 1. Search for metadata using a plugin + * 2. Preview changes before applying + * 3. Apply and show success + * + * This component manages the state machine between steps. + */ +export function MetadataApplyFlow({ + opened, + onClose, + plugin, + entityId, + entityTitle, + contentType = "series", + onApplySuccess, +}: MetadataApplyFlowProps) { + const [step, setStep] = useState("search"); + const [selectedResult, setSelectedResult] = + useState(null); + const [appliedFields, setAppliedFields] = useState([]); + + // Reset state when modal opens + useEffect(() => { + if (opened) { + setStep("search"); + setSelectedResult(null); + setAppliedFields([]); + } + }, [opened]); + + // Handle search result selection + const handleSearchSelect = (result: PluginSearchResultDto) => { + setSelectedResult(result); + setStep("preview"); + }; + + // Handle going back to search from preview + const handleBackToSearch = () => { + setStep("search"); + setSelectedResult(null); + }; + + // Handle apply completion + const handleApplyComplete = (success: boolean, fields: string[]) => { + if (success) { + setAppliedFields(fields); + setStep("success"); + notifications.show({ + title: "Metadata Applied", + message: `Updated ${fields.length} field${fields.length !== 1 ? "s" : ""} from ${plugin.pluginDisplayName}`, + color: "green", + icon: , + }); + onApplySuccess?.(); + } else { + notifications.show({ + title: "No Changes Applied", + message: "No fields were updated. Check field locks and permissions.", + color: "yellow", + }); + } + }; + + // Handle close with cleanup + const handleClose = () => { + onClose(); + // Reset state after modal animation completes + setTimeout(() => { + setStep("search"); + setSelectedResult(null); + setAppliedFields([]); + }, 200); + }; + + // Use searchModal directly for the search step, preview modal for preview/success + if (step === "search") { + return ( + + ); + } + + // Preview and success steps use a different modal + return ( + + {step === "success" ? ( + <> + + + + Metadata Applied + + ) : ( + <> + + Preview Changes + + )} + + } + size="lg" + > + {step === "preview" && selectedResult && ( + + )} + + {step === "success" && ( +
+ + + + + + Successfully updated metadata + + + Applied {appliedFields.length} field + {appliedFields.length !== 1 ? "s" : ""}:{" "} + {appliedFields.join(", ")} + + +
+ )} +
+ ); +} diff --git a/web/src/components/metadata/MetadataPreview.test.tsx b/web/src/components/metadata/MetadataPreview.test.tsx new file mode 100644 index 00000000..0d73de34 --- /dev/null +++ b/web/src/components/metadata/MetadataPreview.test.tsx @@ -0,0 +1,310 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MetadataPreviewResponse } from "@/api/plugins"; +import { renderWithProviders, screen, waitFor } from "@/test/utils"; +import { MetadataPreview } from "./MetadataPreview"; + +// Mock the plugins API +vi.mock("@/api/plugins", () => ({ + pluginActionsApi: { + previewSeriesMetadata: vi.fn(), + applySeriesMetadata: vi.fn(), + previewBookMetadata: vi.fn(), + applyBookMetadata: vi.fn(), + }, +})); + +import { pluginActionsApi } from "@/api/plugins"; + +const mockPreviewResponse: MetadataPreviewResponse = { + fields: [ + { + field: "title", + currentValue: "Old Title", + proposedValue: "New Title", + status: "will_apply", + }, + { + field: "summary", + currentValue: "Old summary", + proposedValue: "New summary", + status: "locked", + reason: "Field is locked by user", + }, + { + field: "genres", + currentValue: ["Action"], + proposedValue: ["Action", "Adventure"], + status: "will_apply", + }, + { + field: "year", + currentValue: 2023, + proposedValue: 2023, + status: "unchanged", + }, + { + field: "publisher", + currentValue: null, + proposedValue: null, + status: "not_provided", + }, + ], + summary: { + willApply: 2, + locked: 1, + noPermission: 0, + unchanged: 1, + notProvided: 1, + }, + pluginId: "test-plugin-id", + pluginName: "Test Plugin", + externalId: "ext-123", + externalUrl: "https://example.com/series/ext-123", +}; + +describe("MetadataPreview", () => { + const onApplyComplete = vi.fn(); + const onBack = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + ( + pluginActionsApi.previewSeriesMetadata as ReturnType + ).mockResolvedValue(mockPreviewResponse); + ( + pluginActionsApi.applySeriesMetadata as ReturnType + ).mockResolvedValue({ + success: true, + appliedFields: ["title", "genres"], + skippedFields: [], + message: "Applied 2 fields", + }); + }); + + it("shows loading state initially", async () => { + // Make the preview take a while to resolve + ( + pluginActionsApi.previewSeriesMetadata as ReturnType + ).mockImplementation( + () => + new Promise((resolve) => + setTimeout(() => resolve(mockPreviewResponse), 100), + ), + ); + + renderWithProviders( + , + ); + + // Should show loading state while waiting + await waitFor(() => { + expect( + screen.getByText("Fetching metadata from Test Plugin..."), + ).toBeInTheDocument(); + }); + }); + + it("displays field preview table after loading", async () => { + renderWithProviders( + , + ); + + await waitFor(() => { + expect(screen.getByText("Title")).toBeInTheDocument(); + }); + + expect(screen.getByText("Summary")).toBeInTheDocument(); + expect(screen.getByText("Genres")).toBeInTheDocument(); + expect(screen.getByText("Year")).toBeInTheDocument(); + }); + + it("shows current and proposed values", async () => { + renderWithProviders( + , + ); + + await waitFor(() => { + expect(screen.getByText("Old Title")).toBeInTheDocument(); + }); + + expect(screen.getByText("New Title")).toBeInTheDocument(); + }); + + it("displays summary badges", async () => { + renderWithProviders( + , + ); + + await waitFor(() => { + expect(screen.getByText("2 to apply")).toBeInTheDocument(); + }); + + expect(screen.getByText("1 locked")).toBeInTheDocument(); + // Note: unchanged/notProvided fields don't get summary badges + }); + + it("shows apply button with field count", async () => { + renderWithProviders( + , + ); + + await waitFor(() => { + expect(screen.getByText("Apply 2 Fields")).toBeInTheDocument(); + }); + }); + + it("shows back button when onBack is provided", async () => { + renderWithProviders( + , + ); + + await waitFor(() => { + expect(screen.getByText("Back to Search")).toBeInTheDocument(); + }); + }); + + it("shows external link when available", async () => { + renderWithProviders( + , + ); + + await waitFor(() => { + expect(screen.getByText("View on Test Plugin →")).toBeInTheDocument(); + }); + }); + + it("shows error state when preview fails", async () => { + ( + pluginActionsApi.previewSeriesMetadata as ReturnType + ).mockRejectedValue(new Error("Failed to fetch metadata")); + + renderWithProviders( + , + ); + + await waitFor(() => { + expect(screen.getByText("Failed to fetch metadata")).toBeInTheDocument(); + }); + + expect(screen.getByText("Retry")).toBeInTheDocument(); + }); + + it("disables apply button when no fields will be applied", async () => { + const noChangesResponse: MetadataPreviewResponse = { + fields: [ + { + field: "title", + currentValue: "Same Title", + proposedValue: "Same Title", + status: "unchanged", + }, + { + field: "summary", + currentValue: "Old summary", + proposedValue: "New summary", + status: "locked", + reason: "Field is locked by user", + }, + { + field: "year", + currentValue: 2023, + proposedValue: 2023, + status: "unchanged", + }, + ], + summary: { + willApply: 0, + locked: 1, + noPermission: 0, + unchanged: 2, + notProvided: 0, + }, + pluginId: "test-plugin-id", + pluginName: "Test Plugin", + externalId: "ext-123", + externalUrl: "https://example.com/series/ext-123", + }; + + ( + pluginActionsApi.previewSeriesMetadata as ReturnType + ).mockResolvedValue(noChangesResponse); + + renderWithProviders( + , + ); + + await waitFor(() => { + expect(screen.getByText("No Changes to Apply")).toBeInTheDocument(); + }); + }); + + it("calls onApplyComplete when apply succeeds", async () => { + renderWithProviders( + , + ); + + await waitFor(() => { + expect(screen.getByText("Apply 2 Fields")).toBeInTheDocument(); + }); + + // Click apply button + const applyButton = screen.getByText("Apply 2 Fields"); + applyButton.click(); + + await waitFor(() => { + expect(onApplyComplete).toHaveBeenCalledWith(true, ["title", "genres"]); + }); + }); +}); diff --git a/web/src/components/metadata/MetadataPreview.tsx b/web/src/components/metadata/MetadataPreview.tsx new file mode 100644 index 00000000..ba597a4c --- /dev/null +++ b/web/src/components/metadata/MetadataPreview.tsx @@ -0,0 +1,644 @@ +import { + Badge, + Button, + Center, + Checkbox, + Group, + Image, + Loader, + ScrollArea, + Stack, + Table, + Text, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { + IconArrowRight, + IconCheck, + IconEqual, + IconLock, + IconMinus, + IconShieldOff, +} from "@tabler/icons-react"; +import { useMutation } from "@tanstack/react-query"; +import type { + FieldApplyStatus, + MetadataFieldPreview, + MetadataPreviewResponse, +} from "@/api/plugins"; +import { pluginActionsApi } from "@/api/plugins"; + +export interface MetadataPreviewProps { + /** Series ID */ + seriesId: string; + /** Plugin ID */ + pluginId: string; + /** External ID from search result */ + externalId: string; + /** Plugin display name */ + pluginName: string; + /** Content type (only "series" is currently supported) */ + contentType?: "series"; + /** Callback when apply is complete */ + onApplyComplete?: (success: boolean, appliedFields: string[]) => void; + /** Callback to go back to search */ + onBack?: () => void; +} + +interface StatusConfig { + icon: React.ReactNode; + color: string; + label: string; +} + +const STATUS_CONFIG: Record = { + will_apply: { + icon: , + color: "green", + label: "Will be applied", + }, + locked: { + icon: , + color: "yellow", + label: "Field is locked", + }, + no_permission: { + icon: , + color: "red", + label: "Plugin lacks permission", + }, + unchanged: { + icon: , + color: "gray", + label: "Value unchanged", + }, + not_provided: { + icon: , + color: "gray", + label: "Not provided by plugin", + }, +}; + +const FIELD_LABELS: Record = { + title: "Title", + alternateTitles: "Alternate Titles", + summary: "Summary", + year: "Year", + status: "Status", + publisher: "Publisher", + genres: "Genres", + tags: "Tags", + language: "Language", + ageRating: "Age Rating", + readingDirection: "Reading Direction", + totalBookCount: "Total Books", + externalLinks: "External Links", + rating: "Rating", + externalRatings: "External Ratings", + coverUrl: "Cover", +}; + +/** + * Component to preview metadata changes before applying + * + * Shows a table with: + * - Checkbox to select/deselect field + * - Field name + * - Current value + * - Proposed value + * - Status icon (will apply, locked, no permission, unchanged, not provided) + */ +export function MetadataPreview({ + seriesId, + pluginId, + externalId, + pluginName, + contentType = "series", + onApplyComplete, + onBack, +}: MetadataPreviewProps) { + // Track which fields are selected for application + const [selectedFields, setSelectedFields] = React.useState>( + new Set(), + ); + const [initialized, setInitialized] = React.useState(false); + + // Fetch preview data + const previewMutation = useMutation({ + mutationFn: async () => { + if (contentType === "series") { + return pluginActionsApi.previewSeriesMetadata( + seriesId, + pluginId, + externalId, + ); + } + // For books, use bookId as seriesId (naming is confusing but intentional) + // TODO: Add previewBookMetadata when book support is added + return pluginActionsApi.previewSeriesMetadata( + seriesId, + pluginId, + externalId, + ); + }, + }); + + // Initialize selected fields when preview data is loaded + React.useEffect(() => { + if (previewMutation.data && !initialized) { + const applyableFields = previewMutation.data.fields + .filter((f) => f.status === "will_apply") + .map((f) => f.field); + setSelectedFields(new Set(applyableFields)); + setInitialized(true); + } + }, [previewMutation.data, initialized]); + + // Toggle field selection + const toggleField = (field: string) => { + setSelectedFields((prev) => { + const next = new Set(prev); + if (next.has(field)) { + next.delete(field); + } else { + next.add(field); + } + return next; + }); + }; + + // Apply metadata mutation + const applyMutation = useMutation({ + mutationFn: async () => { + const fieldsArray = + selectedFields.size > 0 ? Array.from(selectedFields) : undefined; + if (contentType === "series") { + return pluginActionsApi.applySeriesMetadata( + seriesId, + pluginId, + externalId, + fieldsArray, + ); + } + // TODO: Add applyBookMetadata when book support is added + return pluginActionsApi.applySeriesMetadata( + seriesId, + pluginId, + externalId, + fieldsArray, + ); + }, + onSuccess: (data) => { + onApplyComplete?.(data.success, data.appliedFields); + }, + }); + + // Fetch preview on mount + // biome-ignore lint/correctness/useExhaustiveDependencies: only run on mount + React.useEffect(() => { + previewMutation.mutate(); + }, []); + + if (previewMutation.isPending) { + return ( +
+ + + Fetching metadata from {pluginName}... + +
+ ); + } + + if (previewMutation.isError) { + return ( +
+ + + {previewMutation.error?.message || "Failed to fetch metadata"} + + + {onBack && ( + + )} + + + +
+ ); + } + + const preview = previewMutation.data; + if (!preview) { + return null; + } + + const canApply = selectedFields.size > 0; + + return ( + + {/* Header */} + + + + Preview changes from {pluginName} + + {preview.externalUrl && ( + + View on {pluginName} → + + )} + + + + + {/* Fields table */} + + + + + Status + + Field + + Current + + New + + + + {preview.fields.map((field) => ( + toggleField(field.field)} + seriesId={seriesId} + /> + ))} + +
+
+ + {/* Actions */} + + {onBack && ( + + )} + + +
+ ); +} + +interface SummaryBadgesProps { + summary: MetadataPreviewResponse["summary"]; + selectedCount: number; +} + +function SummaryBadges({ summary, selectedCount }: SummaryBadgesProps) { + return ( + + {selectedCount > 0 && ( + + {selectedCount} to apply + + )} + {summary.locked > 0 && ( + + {summary.locked} locked + + )} + {summary.noPermission > 0 && ( + + {summary.noPermission} denied + + )} + + ); +} + +interface FieldRowProps { + field: MetadataFieldPreview; + isSelected: boolean; + onToggle: () => void; + /** Series ID for building cover thumbnail URL */ + seriesId: string; +} + +function FieldRow({ field, isSelected, onToggle, seriesId }: FieldRowProps) { + const config = STATUS_CONFIG[field.status]; + const isApplyable = field.status === "will_apply"; + const isActive = isApplyable && isSelected; + + return ( + + {/* Status icon / Checkbox */} + + {isApplyable ? ( + e.stopPropagation()} + size="sm" + /> + ) : ( + + + {config.icon} + + + )} + + + {/* Field name */} + + + {FIELD_LABELS[field.field] || field.field} + + + + {/* Current value */} + + + + + {/* Arrow */} + + {isActive && } + + + {/* Proposed value */} + + + + + ); +} + +interface ValueDisplayProps { + value: unknown; + highlight?: boolean; + /** Field name to enable special rendering (e.g., coverUrl shows image) */ + fieldName?: string; + /** Series ID for building cover thumbnail URL (used for current cover) */ + seriesId?: string; + /** Whether this is the current value (uses series thumbnail) or proposed (uses external URL) */ + isCurrent?: boolean; +} + +function ValueDisplay({ + value, + highlight, + fieldName, + seriesId, + isCurrent, +}: ValueDisplayProps) { + // Handle cover URL - display as thumbnail image + if (fieldName === "coverUrl") { + // For current value, use the series thumbnail from the server + // For proposed value, use the external URL from the plugin + const coverSrc = isCurrent + ? seriesId + ? `/api/v1/series/${seriesId}/thumbnail` + : undefined + : typeof value === "string" + ? value + : undefined; + const tooltipLabel = isCurrent + ? "Current cover" + : (value as string) || "No cover"; + + return ( + + Cover + + ); + } + + if (value === null || value === undefined) { + return ( + + — + + ); + } + + // Handle arrays (genres, tags, external links, ratings, etc.) + if (Array.isArray(value)) { + if (value.length === 0) { + return ( + + — + + ); + } + // Check if it's an array of objects + if (typeof value[0] === "object" && value[0] !== null) { + const firstItem = value[0] as Record; + + // Check if it's an array of ratings (has score property) + if ("score" in firstItem) { + const ratings = value as Array<{ + score: number; + maxScore?: number; + source?: string; + }>; + return ( + + {ratings.slice(0, 3).map((rating, idx) => ( + + {rating.source}: {Number(rating.score).toFixed(1)}/ + {rating.maxScore || 10} + + ))} + {ratings.length > 3 && ( + + +{ratings.length - 3} + + )} + + ); + } + + // Check if it's alternate titles (has label and title properties, no url) + if ( + "label" in firstItem && + "title" in firstItem && + !("url" in firstItem) + ) { + const altTitles = value as Array<{ label: string; title: string }>; + return ( + + {altTitles.slice(0, 3).map((item, idx) => ( + + + {item.title.length > 20 + ? `${item.title.slice(0, 20)}...` + : item.title} + + + ))} + {altTitles.length > 3 && ( + + +{altTitles.length - 3} + + )} + + ); + } + + // Otherwise treat as external links (has label/url properties) + const items = value as Array<{ label?: string; url?: string }>; + return ( + + {items.slice(0, 3).map((item, idx) => ( + + {item.label || item.url || "Link"} + + ))} + {items.length > 3 && ( + + +{items.length - 3} + + )} + + ); + } + // Handle simple arrays (strings) + return ( + + {value.slice(0, 5).map((item, idx) => ( + + {String(item)} + + ))} + {value.length > 5 && ( + + +{value.length - 5} + + )} + + ); + } + + // Handle rating objects + if (typeof value === "object" && value !== null) { + const obj = value as Record; + if ("score" in obj) { + const score = Number(obj.score) || 0; + const source = obj.source as string; + return ( + + {score.toFixed(1)}/100{source && ` (${source})`} + + ); + } + // Generic object - show as JSON summary + return ( + + {JSON.stringify(obj).slice(0, 50)}... + + ); + } + + // Handle strings/numbers + const displayValue = String(value); + const truncated = + displayValue.length > 100 + ? `${displayValue.substring(0, 100)}...` + : displayValue; + + return ( + + + {truncated} + + + ); +} + +// Import React for useEffect +import React from "react"; diff --git a/web/src/components/metadata/MetadataSearchModal.test.tsx b/web/src/components/metadata/MetadataSearchModal.test.tsx new file mode 100644 index 00000000..633e39ca --- /dev/null +++ b/web/src/components/metadata/MetadataSearchModal.test.tsx @@ -0,0 +1,216 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginActionDto } from "@/api/plugins"; +import { renderWithProviders, screen, waitFor } from "@/test/utils"; +import { MetadataSearchModal } from "./MetadataSearchModal"; + +// Mock the plugins API +vi.mock("@/api/plugins", () => ({ + pluginsApi: { + searchMetadata: vi.fn(), + }, +})); + +import { pluginsApi } from "@/api/plugins"; + +const mockPlugin: PluginActionDto = { + pluginId: "test-plugin-id", + pluginName: "test-plugin", + pluginDisplayName: "Test Plugin", + actionType: "metadata_search", + label: "Search Test Plugin", +}; + +const mockSearchResults = { + success: true, + result: { + results: [ + { + externalId: "ext-1", + title: "Test Series", + alternateTitles: ["Alt Title 1"], + year: 2024, + coverUrl: "https://example.com/cover.jpg", + relevanceScore: 0.95, + preview: { + status: "Ongoing", + genres: ["Action", "Adventure"], + }, + }, + { + externalId: "ext-2", + title: "Another Series", + alternateTitles: [], + year: 2023, + coverUrl: null, + relevanceScore: 0.8, + preview: null, + }, + ], + }, + latencyMs: 150, +}; + +describe("MetadataSearchModal", () => { + const onClose = vi.fn(); + const onSelect = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + (pluginsApi.searchMetadata as ReturnType).mockResolvedValue( + mockSearchResults, + ); + }); + + it("renders modal with plugin name in title", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Search Test Plugin")).toBeInTheDocument(); + }); + + it("shows initial query in search input", () => { + renderWithProviders( + , + ); + + expect(screen.getByDisplayValue("Initial Search")).toBeInTheDocument(); + }); + + it("shows hint when query is too short", () => { + renderWithProviders( + , + ); + + expect( + screen.getByText("Enter at least 2 characters to search"), + ).toBeInTheDocument(); + }); + + it("shows search results after searching", async () => { + renderWithProviders( + , + ); + + // Wait for debounced search and results + await waitFor( + () => { + expect(screen.getByText("Test Series")).toBeInTheDocument(); + }, + { timeout: 1000 }, + ); + + expect(screen.getByText("Another Series")).toBeInTheDocument(); + expect(screen.getByText("2024")).toBeInTheDocument(); + expect(screen.getByText("Ongoing")).toBeInTheDocument(); + }); + + it("displays result count", async () => { + renderWithProviders( + , + ); + + await waitFor( + () => { + expect(screen.getByText("2 results found")).toBeInTheDocument(); + }, + { timeout: 1000 }, + ); + }); + + it("shows no results message when search returns empty", async () => { + (pluginsApi.searchMetadata as ReturnType).mockResolvedValue({ + success: true, + result: { results: [] }, + latencyMs: 100, + }); + + renderWithProviders( + , + ); + + await waitFor( + () => { + expect( + screen.getByText(/No results found for "NonExistent"/), + ).toBeInTheDocument(); + }, + { timeout: 1000 }, + ); + }); + + it("shows error message when search fails", async () => { + (pluginsApi.searchMetadata as ReturnType).mockResolvedValue({ + success: false, + error: "Network error", + latencyMs: 100, + }); + + renderWithProviders( + , + ); + + await waitFor( + () => { + expect(screen.getByText("Network error")).toBeInTheDocument(); + }, + { timeout: 1000 }, + ); + + expect(screen.getByText("Retry")).toBeInTheDocument(); + }); + + it("does not render when closed", () => { + renderWithProviders( + , + ); + + expect(screen.queryByText("Search Test Plugin")).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/metadata/MetadataSearchModal.tsx b/web/src/components/metadata/MetadataSearchModal.tsx new file mode 100644 index 00000000..6b6ef548 --- /dev/null +++ b/web/src/components/metadata/MetadataSearchModal.tsx @@ -0,0 +1,315 @@ +import { + Badge, + Box, + Button, + Center, + Group, + Image, + Loader, + Modal, + ScrollArea, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useDebouncedValue } from "@mantine/hooks"; +import { IconSearch, IconX } from "@tabler/icons-react"; +import { useMutation } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + type PluginActionDto, + type PluginSearchResultDto, + pluginsApi, +} from "@/api/plugins"; + +export interface MetadataSearchModalProps { + /** Whether the modal is open */ + opened: boolean; + /** Callback to close the modal */ + onClose: () => void; + /** The plugin to search with */ + plugin: PluginActionDto; + /** Initial search query (e.g., series title) */ + initialQuery?: string; + /** Content type to search for (only "series" is currently supported) */ + contentType?: "series"; + /** Callback when a result is selected */ + onSelect: (result: PluginSearchResultDto) => void; +} + +/** + * Modal for searching metadata from a plugin + * + * Features: + * - Debounced search input + * - Results list with cover thumbnails + * - Loading and error states + */ +export function MetadataSearchModal({ + opened, + onClose, + plugin, + initialQuery = "", + contentType = "series", + onSelect, +}: MetadataSearchModalProps) { + const [query, setQuery] = useState(initialQuery); + const [debouncedQuery] = useDebouncedValue(query, 400); + const [results, setResults] = useState([]); + + // Track request ID to prevent race conditions in debounced search. + // Each search gets a unique ID, and we only update results if the response + // matches the latest request ID. + const requestIdRef = useRef(0); + const lastSearchedQueryRef = useRef(null); + + // Perform search with race condition protection + const performSearch = useCallback( + async (searchQuery: string) => { + // Increment request ID for this search + const currentRequestId = ++requestIdRef.current; + lastSearchedQueryRef.current = searchQuery; + + try { + const response = await pluginsApi.searchMetadata( + plugin.pluginId, + searchQuery, + contentType, + ); + + // Only update results if this is still the latest request + if (currentRequestId !== requestIdRef.current) { + return; // Stale request, ignore results + } + + if (!response.success || !response.result) { + throw new Error(response.error || "Search failed"); + } + + const data = response.result as { results: PluginSearchResultDto[] }; + setResults(data.results || []); + } catch (error) { + // Only propagate error if this is still the latest request + if (currentRequestId !== requestIdRef.current) { + return; // Stale request, ignore error + } + throw error; + } + }, + [plugin.pluginId, contentType], + ); + + // Search mutation with race condition protection + const searchMutation = useMutation({ + mutationFn: performSearch, + }); + + // Reset state and trigger search when modal opens + // biome-ignore lint/correctness/useExhaustiveDependencies: mutate is stable, only trigger on open/query change + useEffect(() => { + if (opened) { + setQuery(initialQuery); + setResults([]); + // Reset request tracking + lastSearchedQueryRef.current = null; + // Trigger search immediately if we have a valid initial query + if (initialQuery.trim().length >= 2) { + searchMutation.mutate(initialQuery); + } + } + }, [opened, initialQuery]); + + // Auto-search when debounced query changes (for user typing) + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally only trigger on query change + useEffect(() => { + // Skip if modal just opened (handled by the effect above) + if (!opened) return; + + const trimmedQuery = debouncedQuery.trim(); + if (trimmedQuery.length >= 2) { + // Only search if the query is different from what we last searched. + // This prevents duplicate searches when debounced value catches up. + if (trimmedQuery !== lastSearchedQueryRef.current) { + searchMutation.mutate(debouncedQuery); + } + } else { + setResults([]); + } + }, [debouncedQuery]); + + const handleSelect = (result: PluginSearchResultDto) => { + onSelect(result); + }; + + return ( + + + Search {plugin.pluginDisplayName} + + } + size="lg" + scrollAreaComponent={ScrollArea.Autosize} + > + + {/* Search input */} + setQuery(e.currentTarget.value)} + leftSection={} + rightSection={ + query && ( + setQuery("")} + /> + ) + } + autoFocus + /> + + {/* Loading state */} + {searchMutation.isPending && ( +
+ +
+ )} + + {/* Error state */} + {searchMutation.isError && ( +
+ + + {searchMutation.error?.message || "Search failed"} + + + +
+ )} + + {/* No results */} + {!searchMutation.isPending && + !searchMutation.isError && + debouncedQuery.trim().length >= 2 && + results.length === 0 && ( +
+ + No results found for "{debouncedQuery}" + +
+ )} + + {/* Results list */} + {results.length > 0 && ( + + + {results.length} result{results.length !== 1 ? "s" : ""} found + + {results.map((result) => ( + + ))} + + )} + + {/* Initial state hint */} + {!searchMutation.isPending && + !searchMutation.isError && + debouncedQuery.trim().length < 2 && + results.length === 0 && ( +
+ + Enter at least 2 characters to search + +
+ )} +
+
+ ); +} + +interface SearchResultCardProps { + result: PluginSearchResultDto; + onSelect: (result: PluginSearchResultDto) => void; +} + +function SearchResultCard({ result, onSelect }: SearchResultCardProps) { + return ( + ({ + border: `1px solid ${theme.colors.dark[4]}`, + borderRadius: theme.radius.sm, + cursor: "pointer", + transition: "background-color 150ms ease", + "&:hover": { + backgroundColor: theme.colors.dark[6], + }, + })} + onClick={() => onSelect(result)} + > + + {/* Cover image */} + {result.title} + + {/* Info */} + + + {result.title} + + + {result.year && ( + + {result.year} + + )} + + {result.alternateTitles && result.alternateTitles.length > 0 && ( + + {result.alternateTitles.slice(0, 2).join(" / ")} + {result.alternateTitles.length > 2 && + ` +${result.alternateTitles.length - 2}`} + + )} + + {result.preview && ( + + {result.preview.status && ( + + {result.preview.status} + + )} + {result.preview.genres?.slice(0, 3).map((genre) => ( + + {genre} + + ))} + + )} + + + + ); +} diff --git a/web/src/components/metadata/index.ts b/web/src/components/metadata/index.ts new file mode 100644 index 00000000..b9b85dd0 --- /dev/null +++ b/web/src/components/metadata/index.ts @@ -0,0 +1,6 @@ +export type { MetadataApplyFlowProps } from "./MetadataApplyFlow"; +export { MetadataApplyFlow } from "./MetadataApplyFlow"; +export type { MetadataPreviewProps } from "./MetadataPreview"; +export { MetadataPreview } from "./MetadataPreview"; +export type { MetadataSearchModalProps } from "./MetadataSearchModal"; +export { MetadataSearchModal } from "./MetadataSearchModal"; diff --git a/web/src/components/reader/hooks/useEpubProgress.ts b/web/src/components/reader/hooks/useEpubProgress.ts index 413ac8ae..8e68a226 100644 --- a/web/src/components/reader/hooks/useEpubProgress.ts +++ b/web/src/components/reader/hooks/useEpubProgress.ts @@ -132,8 +132,8 @@ export function useEpubProgress({ readProgressApi .update(currentBookId, { - currentPage, - progressPercentage: percentage, + current_page: currentPage, + progress_percentage: percentage, completed: isCompleted, }) .then(() => { @@ -263,8 +263,8 @@ export function useEpubProgress({ ) { readProgressApi .update(currentBookId, { - currentPage, - progressPercentage: percentage, + current_page: currentPage, + progress_percentage: percentage, completed: percentage >= 0.98, }) .catch(() => { diff --git a/web/src/components/reader/hooks/useReadProgress.test.tsx b/web/src/components/reader/hooks/useReadProgress.test.tsx index e82e332d..0923f3a5 100644 --- a/web/src/components/reader/hooks/useReadProgress.test.tsx +++ b/web/src/components/reader/hooks/useReadProgress.test.tsx @@ -208,7 +208,7 @@ describe("useReadProgress", () => { // Now it should have been called expect(mockUpdate).toHaveBeenCalledWith("test-book", { - currentPage: 5, + current_page: 5, completed: false, }); }); @@ -277,7 +277,7 @@ describe("useReadProgress", () => { // Should be called immediately without waiting expect(mockUpdate).toHaveBeenCalledWith("test-book", { - currentPage: 25, + current_page: 25, completed: false, }); }); @@ -301,7 +301,7 @@ describe("useReadProgress", () => { }); expect(mockUpdate).toHaveBeenCalledWith("test-book", { - currentPage: 100, + current_page: 100, completed: true, }); }); @@ -444,7 +444,7 @@ describe("useReadProgress", () => { // The save should happen on unmount with final page expect(mockUpdate).toHaveBeenCalledWith("test-book", { - currentPage: 30, + current_page: 30, completed: false, }); diff --git a/web/src/components/reader/hooks/useReadProgress.ts b/web/src/components/reader/hooks/useReadProgress.ts index e0e34dac..57cacfbe 100644 --- a/web/src/components/reader/hooks/useReadProgress.ts +++ b/web/src/components/reader/hooks/useReadProgress.ts @@ -70,7 +70,7 @@ export function useReadProgress({ readProgressApi .update(currentBookId, { - currentPage: page, + current_page: page, completed: page >= currentTotalPages, }) .then((updatedProgress) => { diff --git a/web/src/components/series/ExternalLinks.tsx b/web/src/components/series/ExternalLinks.tsx index 81024106..6be1fff2 100644 --- a/web/src/components/series/ExternalLinks.tsx +++ b/web/src/components/series/ExternalLinks.tsx @@ -1,4 +1,4 @@ -import { ActionIcon, Group, Tooltip } from "@mantine/core"; +import { Badge, Group } from "@mantine/core"; import { IconExternalLink } from "@tabler/icons-react"; import type { ExternalLink } from "@/api/seriesMetadata"; @@ -9,14 +9,14 @@ interface ExternalLinksProps { // Map source names to display names and colors const SOURCE_CONFIG: Record< string, - { name: string; color: string; icon?: string } + { name: string; color: string; abbrev?: string } > = { - myanimelist: { name: "MyAnimeList", color: "#2e51a2" }, + myanimelist: { name: "MyAnimeList", color: "#2e51a2", abbrev: "MAL" }, anilist: { name: "AniList", color: "#02a9ff" }, mangabaka: { name: "MangaBaka", color: "#ff6b35" }, mangadex: { name: "MangaDex", color: "#ff6740" }, kitsu: { name: "Kitsu", color: "#f75239" }, - mangaupdates: { name: "MangaUpdates", color: "#2a4a6d" }, + mangaupdates: { name: "MangaUpdates", color: "#2a4a6d", abbrev: "MU" }, comicvine: { name: "Comic Vine", color: "#e41d25" }, goodreads: { name: "Goodreads", color: "#553b08" }, amazon: { name: "Amazon", color: "#ff9900" }, @@ -34,21 +34,23 @@ export function ExternalLinks({ links }: ExternalLinksProps) { name: link.sourceName, color: "gray", }; + const displayName = config.abbrev || config.name; return ( - - - - - + } + style={{ cursor: "pointer" }} + > + {displayName} + ); })} diff --git a/web/src/components/series/SeriesBookList.test.tsx b/web/src/components/series/SeriesBookList.test.tsx new file mode 100644 index 00000000..176be927 --- /dev/null +++ b/web/src/components/series/SeriesBookList.test.tsx @@ -0,0 +1,303 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { seriesApi } from "@/api/series"; +import { createBook } from "@/mocks/data/factories"; +import { useBulkSelectionStore } from "@/store/bulkSelectionStore"; +import { renderWithProviders, screen, userEvent, waitFor } from "@/test/utils"; +import { SeriesBookList } from "./SeriesBookList"; + +// Mock the series API +vi.mock("@/api/series", () => ({ + seriesApi: { + getBooks: vi.fn(), + }, +})); + +const mockSeriesApi = vi.mocked(seriesApi); + +describe("SeriesBookList", () => { + const seriesId = "test-series-id"; + const seriesName = "Test Series"; + const bookCount = 5; + + const mockBooks = [ + createBook({ id: "book-1", title: "Book One", number: 1 }), + createBook({ id: "book-2", title: "Book Two", number: 2 }), + createBook({ id: "book-3", title: "Book Three", number: 3 }), + createBook({ id: "book-4", title: "Book Four", number: 4 }), + createBook({ id: "book-5", title: "Book Five", number: 5 }), + ]; + + beforeEach(() => { + vi.clearAllMocks(); + // Reset the bulk selection store before each test + useBulkSelectionStore.getState().clearSelection(); + // Default mock to return books + mockSeriesApi.getBooks.mockResolvedValue(mockBooks); + }); + + const renderComponent = () => { + return renderWithProviders( + , + ); + }; + + describe("loading state", () => { + it("should render loading state initially", () => { + // Never resolve to keep loading state + mockSeriesApi.getBooks.mockReturnValue(new Promise(() => {})); + + const { container } = renderComponent(); + + // Mantine Loader uses a span with class mantine-Loader-root + expect( + container.querySelector(".mantine-Loader-root"), + ).toBeInTheDocument(); + }); + }); + + describe("data display", () => { + it("should display books after loading", async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.getByText("1 - Book One")).toBeInTheDocument(); + }); + + expect(screen.getByText("2 - Book Two")).toBeInTheDocument(); + expect(screen.getByText("3 - Book Three")).toBeInTheDocument(); + }); + + it("should display book count in title", async () => { + renderComponent(); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: /Books \(5\)/i }), + ).toBeInTheDocument(); + }); + }); + + it("should display empty state when no books", async () => { + mockSeriesApi.getBooks.mockResolvedValue([]); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByText("No books in this series")).toBeInTheDocument(); + }); + }); + }); + + describe("error state", () => { + it("should display error message on API failure", async () => { + mockSeriesApi.getBooks.mockRejectedValue(new Error("Failed to fetch")); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByText("Failed to load books")).toBeInTheDocument(); + }); + }); + }); + + describe("bulk selection", () => { + it("should render MediaCards with selection props", async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.getByText("1 - Book One")).toBeInTheDocument(); + }); + + // Each book card should have a checkbox for selection + const checkboxes = screen.getAllByRole("checkbox"); + expect(checkboxes.length).toBe(5); + }); + + it("should toggle selection when checkbox is clicked", async () => { + const user = userEvent.setup(); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText("1 - Book One")).toBeInTheDocument(); + }); + + // Click on the first book's checkbox + const checkboxes = screen.getAllByRole("checkbox"); + await user.click(checkboxes[0]); + + // Check that the selection store was updated + const state = useBulkSelectionStore.getState(); + expect(state.selectedIds.has("book-1")).toBe(true); + expect(state.selectionType).toBe("book"); + expect(state.isSelectionMode).toBe(true); + }); + + it("should allow selecting multiple books", async () => { + const user = userEvent.setup(); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText("1 - Book One")).toBeInTheDocument(); + }); + + const checkboxes = screen.getAllByRole("checkbox"); + + // Select first and third books + await user.click(checkboxes[0]); + await user.click(checkboxes[2]); + + const state = useBulkSelectionStore.getState(); + expect(state.selectedIds.has("book-1")).toBe(true); + expect(state.selectedIds.has("book-3")).toBe(true); + expect(state.selectedIds.size).toBe(2); + }); + + it("should deselect book when clicking selected checkbox", async () => { + const user = userEvent.setup(); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText("1 - Book One")).toBeInTheDocument(); + }); + + const checkboxes = screen.getAllByRole("checkbox"); + + // Select then deselect the first book + await user.click(checkboxes[0]); + expect(useBulkSelectionStore.getState().selectedIds.has("book-1")).toBe( + true, + ); + + await user.click(checkboxes[0]); + expect(useBulkSelectionStore.getState().selectedIds.has("book-1")).toBe( + false, + ); + }); + + it("should exit selection mode when all items are deselected", async () => { + const user = userEvent.setup(); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText("1 - Book One")).toBeInTheDocument(); + }); + + const checkboxes = screen.getAllByRole("checkbox"); + + // Select then deselect + await user.click(checkboxes[0]); + expect(useBulkSelectionStore.getState().isSelectionMode).toBe(true); + + await user.click(checkboxes[0]); + expect(useBulkSelectionStore.getState().isSelectionMode).toBe(false); + }); + + it("should show checkbox as checked when item is selected", async () => { + const user = userEvent.setup(); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText("1 - Book One")).toBeInTheDocument(); + }); + + const checkboxes = screen.getAllByRole("checkbox"); + + // Initially unchecked + expect(checkboxes[0]).not.toBeChecked(); + + // Click to select + await user.click(checkboxes[0]); + + // Should now be checked + expect(checkboxes[0]).toBeChecked(); + }); + + it("should not allow selecting books when series are selected", async () => { + // Pre-select a series to lock selection type + useBulkSelectionStore.getState().toggleSelection("series-1", "series"); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByText("1 - Book One")).toBeInTheDocument(); + }); + + // Checkboxes should be disabled since we're in series selection mode + const checkboxes = screen.getAllByRole("checkbox"); + checkboxes.forEach((checkbox) => { + expect(checkbox).toBeDisabled(); + }); + }); + }); + + describe("sorting", () => { + it("should sort books by number ascending by default", async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.getByText("1 - Book One")).toBeInTheDocument(); + }); + + // Get all book titles in order + const titles = screen.getAllByText(/^\d+ - Book/); + expect(titles[0]).toHaveTextContent("1 - Book One"); + expect(titles[4]).toHaveTextContent("5 - Book Five"); + }); + + it("should display current sort option", async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.getByText("Number (Ascending)")).toBeInTheDocument(); + }); + }); + }); + + describe("pagination", () => { + it("should show pagination when there are more books than page size", async () => { + // Create 25 books to trigger pagination (default page size is 20) + const manyBooks = Array.from({ length: 25 }, (_, i) => + createBook({ + id: `book-${i + 1}`, + title: `Book ${i + 1}`, + number: i + 1, + }), + ); + mockSeriesApi.getBooks.mockResolvedValue(manyBooks); + + const { container } = renderWithProviders( + , + ); + + await waitFor(() => { + expect(screen.getByText("1 - Book 1")).toBeInTheDocument(); + }); + + // Mantine Pagination uses a div with class mantine-Pagination-root + expect( + container.querySelector(".mantine-Pagination-root"), + ).toBeInTheDocument(); + }); + + it("should not show pagination when books fit on one page", async () => { + const { container } = renderComponent(); + + await waitFor(() => { + expect(screen.getByText("1 - Book One")).toBeInTheDocument(); + }); + + // Pagination should not be visible for 5 books with default page size of 20 + expect( + container.querySelector(".mantine-Pagination-root"), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/web/src/components/series/SeriesBookList.tsx b/web/src/components/series/SeriesBookList.tsx index 54fabf4c..18a26fa7 100644 --- a/web/src/components/series/SeriesBookList.tsx +++ b/web/src/components/series/SeriesBookList.tsx @@ -6,18 +6,22 @@ import { Menu, Pagination, Select, - SimpleGrid, Stack, Text, Title, } from "@mantine/core"; import { IconSortAscending } from "@tabler/icons-react"; import { useQuery } from "@tanstack/react-query"; -import { useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useCallback, useMemo, useRef, useState } from "react"; import { seriesApi } from "@/api/series"; import { MediaCard } from "@/components/library/MediaCard"; +import { + selectCanSelectType, + selectIsSelectionMode, + useBulkSelectionStore, +} from "@/store/bulkSelectionStore"; import { useUserPreferencesStore } from "@/store/userPreferencesStore"; +import type { Book } from "@/types"; interface SeriesBookListProps { seriesId: string; @@ -47,11 +51,29 @@ export function SeriesBookList({ seriesName: _seriesName, bookCount, }: SeriesBookListProps) { - const navigate = useNavigate(); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [sort, setSort] = useState("number,asc"); + // Bulk selection state - use stable selectors to minimize re-renders + const isSelectionMode = useBulkSelectionStore(selectIsSelectionMode); + const canSelectBooks = useBulkSelectionStore(selectCanSelectType("book")); + const toggleSelection = useBulkSelectionStore( + (state) => state.toggleSelection, + ); + const selectRange = useBulkSelectionStore((state) => state.selectRange); + const getLastSelectedIndex = useBulkSelectionStore( + (state) => state.getLastSelectedIndex, + ); + // Get the Set directly for O(1) lookups - only re-renders when the Set changes + const selectedIds = useBulkSelectionStore((state) => state.selectedIds); + + // Grid ID for range selection tracking + const gridId = `series-books-${seriesId}`; + + // Ref for storing books data for range selection (updated after query) + const booksDataRef = useRef([]); + // Get show deleted preference from user preferences store const showDeletedBooks = useUserPreferencesStore((state) => state.getPreference("library.show_deleted_books"), @@ -112,11 +134,40 @@ export function SeriesBookList({ [paginatedBooks, sortedBooks.length], ); + // Update booksDataRef when data changes (for range selection) + if (paginatedBooks) { + booksDataRef.current = paginatedBooks; + } + const totalPages = data ? Math.ceil(data.total / pageSize) : 1; - const handleBookClick = (bookId: string) => { - navigate(`/books/${bookId}`); - }; + // Handle selection with shift+click range support + const handleSelect = useCallback( + (id: string, shiftKey: boolean, index?: number) => { + if (shiftKey && isSelectionMode && index !== undefined) { + // Shift+click: select range from last selected to current + const lastIndex = getLastSelectedIndex(gridId); + if (lastIndex !== undefined && lastIndex !== index) { + const start = Math.min(lastIndex, index); + const end = Math.max(lastIndex, index); + const rangeIds = booksDataRef.current + .slice(start, end + 1) + .map((item) => item.id); + selectRange(rangeIds, "book"); + return; + } + } + // Normal click: toggle selection + toggleSelection(id, "book", gridId, index); + }, + [ + toggleSelection, + selectRange, + getLastSelectedIndex, + gridId, + isSelectionMode, + ], + ); const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sort)?.label || "Sort"; @@ -194,23 +245,28 @@ export function SeriesBookList({ No books in this series ) : ( <> - - {data?.data.map((book) => ( - ( + handleBookClick(book.id)} - style={{ cursor: "pointer" }} - > - - + type="book" + data={book} + hideSeriesName + index={index} + onSelect={handleSelect} + isSelected={selectedIds.has(book.id)} + isSelectionMode={isSelectionMode} + canBeSelected={canSelectBooks} + /> ))} - + {totalPages > 1 && (
diff --git a/web/src/components/series/SeriesInfoModal.tsx b/web/src/components/series/SeriesInfoModal.tsx new file mode 100644 index 00000000..76c49ce9 --- /dev/null +++ b/web/src/components/series/SeriesInfoModal.tsx @@ -0,0 +1,269 @@ +import { + ActionIcon, + Badge, + Code, + CopyButton, + Group, + Modal, + Paper, + Stack, + Text, + Tooltip, +} from "@mantine/core"; +import { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react"; +import type { FullSeries } from "@/types"; + +export interface SeriesInfoModalProps { + opened: boolean; + onClose: () => void; + series: FullSeries; +} + +function formatDateTime(dateString: string): string { + return new Date(dateString).toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +interface InfoRowProps { + label: string; + value: string | number | null | undefined; + copyable?: boolean; + monospace?: boolean; +} + +function InfoRow({ label, value, copyable, monospace }: InfoRowProps) { + if (value === null || value === undefined || value === "") return null; + + const displayValue = String(value); + + // For copyable monospace values (path, IDs), show inline with copy button + if (copyable && monospace) { + return ( + + + {label} + + + + {displayValue} + + + {({ copied, copy }) => ( + + + {copied ? : } + + + )} + + + + ); + } + + return ( + + + {label} + + + {displayValue} + + + ); +} + +export function SeriesInfoModal({ + opened, + onClose, + series, +}: SeriesInfoModalProps) { + const metadata = series.metadata; + + return ( + + + Series Information + + } + size="lg" + centered + zIndex={1000} + overlayProps={{ + backgroundOpacity: 0.55, + blur: 3, + }} + > + + {/* Basic Info */} + + + + Basic Information + + + {metadata?.titleSort && ( + + )} + + + {series.unreadCount !== null && + series.unreadCount !== undefined && ( + + )} + {metadata?.year && } + {metadata?.status && ( + + )} + {metadata?.readingDirection && ( + + )} + + + + {/* Publishing Info */} + {(metadata?.publisher || + metadata?.imprint || + metadata?.language || + metadata?.ageRating) && ( + + + + Publishing + + {metadata?.publisher && ( + + )} + {metadata?.imprint && ( + + )} + {metadata?.language && ( + + )} + {metadata?.ageRating && ( + + )} + + + )} + + {/* File System Info */} + {series.path && ( + + + + File System + + + + + )} + + {/* Timestamps */} + + + + Timestamps + + + + {metadata?.createdAt && ( + + )} + {metadata?.updatedAt && ( + + )} + + + + {/* Cover Info */} + + + + Cover + + + + Custom Cover + + + {series.hasCustomCover ? "Yes" : "No"} + + + {series.selectedCoverSource && ( + + )} + + + + {/* Identifiers */} + + + + Identifiers + + + + + + + + ); +} diff --git a/web/src/components/series/SeriesMetadataEditModal.tsx b/web/src/components/series/SeriesMetadataEditModal.tsx index 81962728..a5fc58f4 100644 --- a/web/src/components/series/SeriesMetadataEditModal.tsx +++ b/web/src/components/series/SeriesMetadataEditModal.tsx @@ -973,23 +973,21 @@ export function SeriesMetadataEditModal({ )} - {cover.source === "custom" && ( - - { - e.stopPropagation(); - deleteCoverMutation.mutate(cover.id); - }} - loading={deleteCoverMutation.isPending} - aria-label="Delete cover" - > - - - - )} + + { + e.stopPropagation(); + deleteCoverMutation.mutate(cover.id); + }} + loading={deleteCoverMutation.isPending} + aria-label="Delete cover" + > + + + ))} diff --git a/web/src/components/series/index.ts b/web/src/components/series/index.ts index 604e012f..2a25ce1c 100644 --- a/web/src/components/series/index.ts +++ b/web/src/components/series/index.ts @@ -5,6 +5,7 @@ export { ExternalLinks } from "./ExternalLinks"; export { ExternalRatings } from "./ExternalRatings"; export { GenreTagChips } from "./GenreTagChips"; export { SeriesBookList } from "./SeriesBookList"; +export { SeriesInfoModal } from "./SeriesInfoModal"; export { SeriesMetadata } from "./SeriesMetadata"; export { SeriesMetadataEditModal } from "./SeriesMetadataEditModal"; export { SeriesRating } from "./SeriesRating"; diff --git a/web/src/hooks/useEntityEvents.test.ts b/web/src/hooks/useEntityEvents.test.ts index e9610fda..bd983eae 100644 --- a/web/src/hooks/useEntityEvents.test.ts +++ b/web/src/hooks/useEntityEvents.test.ts @@ -91,7 +91,7 @@ describe("useEntityEvents", () => { }); }); - it("should invalidate book queries and record cover update on CoverUpdated event", async () => { + it("should invalidate and refetch queries and record cover update on CoverUpdated event", async () => { let capturedCallback: ((event: EntityChangeEvent) => void) | undefined; vi.spyOn(eventsApi.eventsApi, "subscribeToEntityEvents").mockImplementation( @@ -102,6 +102,7 @@ describe("useEntityEvents", () => { ); const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + const refetchSpy = vi.spyOn(queryClient, "refetchQueries"); // Reset cover updates store before test useCoverUpdatesStore.setState({ updates: {} }); @@ -126,12 +127,18 @@ describe("useEntityEvents", () => { } await waitFor(() => { + // Invalidate the specific series expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["series", "series-123"], }); + // Invalidate all series list queries expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["series"], - refetchType: "all", + }); + // Refetch all active series queries to trigger component re-render + expect(refetchSpy).toHaveBeenCalledWith({ + queryKey: ["series"], + type: "active", }); }); diff --git a/web/src/hooks/useEntityEvents.ts b/web/src/hooks/useEntityEvents.ts index 61c12d78..b9d256e0 100644 --- a/web/src/hooks/useEntityEvents.ts +++ b/web/src/hooks/useEntityEvents.ts @@ -104,17 +104,28 @@ function handleEntityEvent( case "series_created": case "series_updated": case "series_deleted": - case "series_bulk_purged": { + case "series_bulk_purged": + case "series_metadata_updated": { // Invalidate series queries - use default to ensure Recommended section updates queryClient.invalidateQueries({ queryKey: ["series"], }); // Invalidate specific series if it's an update - if (event.type === "series_updated") { + if ( + event.type === "series_updated" || + event.type === "series_metadata_updated" + ) { queryClient.invalidateQueries({ queryKey: ["series", event.series_id], }); + // For metadata updates, also refetch active queries to immediately update the UI + if (event.type === "series_metadata_updated") { + queryClient.refetchQueries({ + queryKey: ["series", event.series_id], + type: "active", + }); + } } // Invalidate library queries @@ -140,22 +151,34 @@ function handleEntityEvent( ); if (event.entity_type === "book") { - // Invalidate book queries to refresh covers + // Invalidate the specific book query queryClient.invalidateQueries({ queryKey: ["books", event.entity_id], }); + // Invalidate all book list queries (marks them as stale) queryClient.invalidateQueries({ queryKey: ["books"], - refetchType: "all", + }); + // Force immediate refetch of active queries to trigger component re-render + // This ensures MediaCard components pick up the new cache-busting timestamp + queryClient.refetchQueries({ + queryKey: ["books"], + type: "active", }); } else if (event.entity_type === "series") { - // Invalidate series queries to refresh covers + // Invalidate the specific series query queryClient.invalidateQueries({ queryKey: ["series", event.entity_id], }); + // Invalidate all series list queries (marks them as stale) queryClient.invalidateQueries({ queryKey: ["series"], - refetchType: "all", + }); + // Force immediate refetch of active queries to trigger component re-render + // This ensures MediaCard components pick up the new cache-busting timestamp + queryClient.refetchQueries({ + queryKey: ["series"], + type: "active", }); } break; diff --git a/web/src/index.css b/web/src/index.css index c0083352..3c1d59e7 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -179,3 +179,48 @@ button:focus-visible { opacity: 1; pointer-events: auto; } + +/* Hide read button overlay when in selection mode. + !important is needed to override the :hover pseudo-class specificity. */ +.media-card--selection-mode .media-card-read-overlay { + display: none !important; +} + +/* ============================================ + Bulk Selection Styles + ============================================ */ + +/* Selection checkbox - positioned top-left of card cover */ +.media-card-checkbox { + position: absolute; + top: 8px; + left: 8px; + z-index: 5; + opacity: 0; + transition: opacity 0.15s ease; +} + +/* Show checkbox on card hover */ +.media-card-cover:hover .media-card-checkbox { + opacity: 1; +} + +/* Always show checkbox when in selection mode */ +.media-card-checkbox--visible { + opacity: 1; +} + +/* Always show checkbox when selected */ +.media-card-checkbox--selected { + opacity: 1; +} + +/* Disabled card styling (type mismatch during selection) */ +.media-card--disabled { + opacity: 0.5; +} + +/* Selected card styling - orange border applied via inline style */ +.media-card--selected { + /* Border applied via inline style for better specificity with isNew state */ +} diff --git a/web/src/mocks/data/factories.ts b/web/src/mocks/data/factories.ts index 1b3791ea..3d892da4 100644 --- a/web/src/mocks/data/factories.ts +++ b/web/src/mocks/data/factories.ts @@ -29,6 +29,13 @@ export type TaskMetricsSummaryDto = export type TaskTypeMetricsDto = components["schemas"]["TaskTypeMetricsDto"]; export type QueueHealthMetricsDto = components["schemas"]["QueueHealthMetricsDto"]; +export type PluginMetricsResponse = + components["schemas"]["PluginMetricsResponse"]; +export type PluginMetricsSummaryDto = + components["schemas"]["PluginMetricsSummaryDto"]; +export type PluginMetricsDto = components["schemas"]["PluginMetricsDto"]; +export type PluginMethodMetricsDto = + components["schemas"]["PluginMethodMetricsDto"]; export type TaskResponse = components["schemas"]["TaskResponse"]; export type TaskStats = components["schemas"]["TaskStats"]; export type SettingDto = components["schemas"]["SettingDto"]; @@ -925,3 +932,92 @@ export const createList = ( factory: (index: number) => T, count: number, ): T[] => Array.from({ length: count }, (_, i) => factory(i)); + +/** + * Plugin method metrics factory - matches PluginMethodMetricsDto schema + */ +export const createPluginMethodMetrics = ( + overrides: Partial = {}, +): PluginMethodMetricsDto => ({ + method: + overrides.method || faker.helpers.arrayElement(["search", "get", "match"]), + requests_total: faker.number.int({ min: 50, max: 500 }), + requests_success: faker.number.int({ min: 45, max: 480 }), + requests_failed: faker.number.int({ min: 0, max: 20 }), + avg_duration_ms: faker.number.float({ min: 100, max: 500 }), + ...overrides, +}); + +/** + * Plugin metrics factory - matches PluginMetricsDto schema + */ +export const createPluginMetrics = ( + overrides: Partial = {}, +): PluginMetricsDto => { + const requests_total = + overrides.requests_total ?? faker.number.int({ min: 100, max: 1000 }); + const requests_failed = + overrides.requests_failed ?? + faker.number.int({ min: 0, max: Math.floor(requests_total * 0.1) }); + const requests_success = + overrides.requests_success ?? requests_total - requests_failed; + const error_rate_pct = + requests_total > 0 ? (requests_failed / requests_total) * 100 : 0; + + return { + plugin_id: faker.string.uuid(), + plugin_name: faker.helpers.arrayElement([ + "MangaBaka", + "ComicVine", + "AniList", + "MyAnimeList", + ]), + requests_total, + requests_success, + requests_failed, + avg_duration_ms: faker.number.float({ min: 150, max: 500 }), + rate_limit_rejections: faker.number.int({ min: 0, max: 10 }), + error_rate_pct: Number(error_rate_pct.toFixed(2)), + last_success: faker.date.recent().toISOString(), + last_failure: faker.datatype.boolean() + ? faker.date.recent().toISOString() + : null, + health_status: faker.helpers.arrayElement([ + "healthy", + "degraded", + "unhealthy", + ]), + by_method: null, + failure_counts: null, + ...overrides, + }; +}; + +/** + * Plugin metrics summary factory - matches PluginMetricsSummaryDto schema + */ +export const createPluginMetricsSummary = ( + overrides: Partial = {}, +): PluginMetricsSummaryDto => ({ + total_plugins: faker.number.int({ min: 1, max: 5 }), + healthy_plugins: faker.number.int({ min: 0, max: 4 }), + degraded_plugins: faker.number.int({ min: 0, max: 2 }), + unhealthy_plugins: faker.number.int({ min: 0, max: 1 }), + total_requests: faker.number.int({ min: 500, max: 5000 }), + total_success: faker.number.int({ min: 450, max: 4800 }), + total_failed: faker.number.int({ min: 0, max: 200 }), + total_rate_limit_rejections: faker.number.int({ min: 0, max: 50 }), + ...overrides, +}); + +/** + * Plugin metrics response factory - matches PluginMetricsResponse schema + */ +export const createPluginMetricsResponse = ( + overrides: Partial = {}, +): PluginMetricsResponse => ({ + updated_at: new Date().toISOString(), + summary: createPluginMetricsSummary(), + plugins: [], + ...overrides, +}); diff --git a/web/src/mocks/handlers/cleanup.ts b/web/src/mocks/handlers/cleanup.ts index b1b88c1e..d7618426 100644 --- a/web/src/mocks/handlers/cleanup.ts +++ b/web/src/mocks/handlers/cleanup.ts @@ -3,9 +3,13 @@ */ import { delay, HttpResponse, http } from "msw"; +import type { components } from "@/types/api.generated"; + +type OrphanStatsDto = components["schemas"]["OrphanStatsDto"]; +type CleanupResultDto = components["schemas"]["CleanupResultDto"]; // Mock data for orphan stats -let mockOrphanStats = { +let mockOrphanStats: Omit = { orphaned_thumbnails: 42, orphaned_covers: 5, total_size_bytes: 15_728_640, // ~15 MB @@ -71,12 +75,12 @@ export const cleanupHandlers = [ http.delete("/api/v1/admin/cleanup-orphans", async () => { await delay(500); - const result = { + const result: CleanupResultDto = { thumbnails_deleted: mockOrphanStats.orphaned_thumbnails, covers_deleted: mockOrphanStats.orphaned_covers, bytes_freed: mockOrphanStats.total_size_bytes, failures: 0, - errors: [] as string[], + errors: [], }; // Reset mock stats after cleanup diff --git a/web/src/mocks/handlers/index.ts b/web/src/mocks/handlers/index.ts index c08987e8..621c72d2 100644 --- a/web/src/mocks/handlers/index.ts +++ b/web/src/mocks/handlers/index.ts @@ -16,6 +16,7 @@ import { libraryHandlers } from "./libraries"; import { metadataHandlers } from "./metadata"; import { metricsHandlers } from "./metrics"; import { pdfCacheHandlers } from "./pdfCache"; +import { pluginsHandlers } from "./plugins"; import { seriesHandlers } from "./series"; import { settingsHandlers } from "./settings"; import { sharingTagsHandlers } from "./sharingTags"; @@ -120,6 +121,7 @@ export const handlers = [ ...metricsHandlers, ...tasksHandlers, ...duplicatesHandlers, + ...pluginsHandlers, ...utilityHandlers, ]; @@ -135,6 +137,7 @@ export { libraryHandlers } from "./libraries"; export { metadataHandlers } from "./metadata"; export { metricsHandlers } from "./metrics"; export { pdfCacheHandlers } from "./pdfCache"; +export { pluginsHandlers } from "./plugins"; export { seriesHandlers } from "./series"; export { settingsHandlers } from "./settings"; export { sharingTagsHandlers } from "./sharingTags"; diff --git a/web/src/mocks/handlers/metadata.ts b/web/src/mocks/handlers/metadata.ts index 0e3fdaee..530626e7 100644 --- a/web/src/mocks/handlers/metadata.ts +++ b/web/src/mocks/handlers/metadata.ts @@ -3,6 +3,34 @@ */ import { delay, HttpResponse, http } from "msw"; +import type { components } from "@/types/api.generated"; + +type GenreDto = components["schemas"]["GenreDto"]; +type TagDto = components["schemas"]["TagDto"]; + +// Helper to convert internal mock genre to GenreDto +const toGenreDto = (g: { + id: string; + name: string; + series_count: number; +}): GenreDto => ({ + id: g.id, + name: g.name, + seriesCount: g.series_count, + createdAt: "2024-01-01T00:00:00Z", +}); + +// Helper to convert internal mock tag to TagDto +const toTagDto = (t: { + id: string; + name: string; + series_count: number; +}): TagDto => ({ + id: t.id, + name: t.name, + seriesCount: t.series_count, + createdAt: "2024-01-01T00:00:00Z", +}); // Mock genres data - includes all genres used in series metadata const mockGenres = [ @@ -112,12 +140,7 @@ export const metadataHandlers = [ // Return paginated response format expected by the API client return HttpResponse.json({ - data: paginatedGenres.map((g) => ({ - id: g.id, - name: g.name, - seriesCount: g.series_count, - createdAt: "2024-01-01T00:00:00Z", - })), + data: paginatedGenres.map(toGenreDto), page, pageSize, total: mockGenres.length, @@ -158,7 +181,7 @@ export const metadataHandlers = [ "Action", "Adventure", ]; - const genres = genreNames.map((name, index) => { + const genres: GenreDto[] = genreNames.map((name, index) => { const genre = mockGenres.find((g) => g.name === name); return { id: genre?.id || `genre-series-${params.seriesId}-${index}`, @@ -178,7 +201,7 @@ export const metadataHandlers = [ genreNames?: string[]; }; const names = body.genreNames || []; - const genres = names.map((name, index) => ({ + const genres: GenreDto[] = names.map((name, index) => ({ id: `genre-${index}`, name, seriesCount: 1, @@ -194,12 +217,13 @@ export const metadataHandlers = [ genreId?: string; genreName?: string; }; - return HttpResponse.json({ + const genre: GenreDto = { id: body.genreId || `genre-${params.seriesId}-${Date.now()}`, name: body.genreName || "New Genre", seriesCount: 1, createdAt: new Date().toISOString(), - }); + }; + return HttpResponse.json(genre); }), // Remove a genre from a series @@ -227,12 +251,7 @@ export const metadataHandlers = [ // Return paginated response format expected by the API client return HttpResponse.json({ - data: paginatedTags.map((t) => ({ - id: t.id, - name: t.name, - seriesCount: t.series_count, - createdAt: "2024-01-01T00:00:00Z", - })), + data: paginatedTags.map(toTagDto), page, pageSize, total: mockTags.length, @@ -273,7 +292,7 @@ export const metadataHandlers = [ "adventure", "action", ]; - const tags = tagNames.map((name, index) => { + const tags: TagDto[] = tagNames.map((name, index) => { const tag = mockTags.find((t) => t.name === name); return { id: tag?.id || `tag-series-${params.seriesId}-${index}`, @@ -293,7 +312,7 @@ export const metadataHandlers = [ tagNames?: string[]; }; const names = body.tagNames || []; - const tags = names.map((name, index) => ({ + const tags: TagDto[] = names.map((name, index) => ({ id: `tag-${index}`, name, seriesCount: 1, @@ -306,12 +325,13 @@ export const metadataHandlers = [ http.post("/api/v1/series/:seriesId/tags", async ({ params, request }) => { await delay(100); const body = (await request.json()) as { tagId?: string; tagName?: string }; - return HttpResponse.json({ + const tag: TagDto = { id: body.tagId || `tag-${params.seriesId}-${Date.now()}`, name: body.tagName || "new-tag", seriesCount: 1, createdAt: new Date().toISOString(), - }); + }; + return HttpResponse.json(tag); }), // Remove a tag from a series diff --git a/web/src/mocks/handlers/metrics.ts b/web/src/mocks/handlers/metrics.ts index 4bd219e2..009bb444 100644 --- a/web/src/mocks/handlers/metrics.ts +++ b/web/src/mocks/handlers/metrics.ts @@ -3,12 +3,17 @@ */ import { delay, HttpResponse, http } from "msw"; +import type { components } from "@/types/api.generated"; import { createInventoryMetrics, + createPluginMethodMetrics, + createPluginMetrics, createTaskMetrics, createTaskTypeMetrics, } from "../data/factories"; +type PluginMetricsResponse = components["schemas"]["PluginMetricsResponse"]; + export const metricsHandlers = [ // Get inventory metrics http.get("/api/v1/metrics/inventory", async () => { @@ -265,4 +270,155 @@ export const metricsHandlers = [ deleted_count: Math.floor(Math.random() * 10000), }); }), + + // Get plugin metrics + http.get("/api/v1/metrics/plugins", async () => { + await delay(100); + + // Create realistic plugin metrics with method breakdowns + const mangabakaMetrics = createPluginMetrics({ + plugin_id: "plugin-mangabaka", + plugin_name: "MangaBaka", + requests_total: 1250, + requests_success: 1198, + requests_failed: 52, + avg_duration_ms: 285.5, + rate_limit_rejections: 8, + error_rate_pct: 4.16, + health_status: "healthy", + last_success: new Date(Date.now() - 300000).toISOString(), // 5 min ago + last_failure: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago + by_method: { + search: createPluginMethodMetrics({ + method: "search", + requests_total: 450, + requests_success: 438, + requests_failed: 12, + avg_duration_ms: 320.5, + }), + get: createPluginMethodMetrics({ + method: "get", + requests_total: 680, + requests_success: 665, + requests_failed: 15, + avg_duration_ms: 245.2, + }), + match: createPluginMethodMetrics({ + method: "match", + requests_total: 120, + requests_success: 95, + requests_failed: 25, + avg_duration_ms: 380.8, + }), + }, + failure_counts: { + TIMEOUT: 28, + RPC_ERROR: 15, + RATE_LIMITED: 9, + }, + }); + + const comicvineMetrics = createPluginMetrics({ + plugin_id: "plugin-comicvine", + plugin_name: "ComicVine", + requests_total: 340, + requests_success: 285, + requests_failed: 55, + avg_duration_ms: 425.3, + rate_limit_rejections: 22, + error_rate_pct: 16.18, + health_status: "degraded", + last_success: new Date(Date.now() - 1800000).toISOString(), // 30 min ago + last_failure: new Date(Date.now() - 600000).toISOString(), // 10 min ago + by_method: { + search: createPluginMethodMetrics({ + method: "search", + requests_total: 200, + requests_success: 165, + requests_failed: 35, + avg_duration_ms: 480.2, + }), + get: createPluginMethodMetrics({ + method: "get", + requests_total: 140, + requests_success: 120, + requests_failed: 20, + avg_duration_ms: 350.1, + }), + }, + failure_counts: { + RATE_LIMITED: 32, + TIMEOUT: 18, + AUTH_FAILED: 5, + }, + }); + + const anilistMetrics = createPluginMetrics({ + plugin_id: "plugin-anilist", + plugin_name: "AniList", + requests_total: 890, + requests_success: 888, + requests_failed: 2, + avg_duration_ms: 125.8, + rate_limit_rejections: 0, + error_rate_pct: 0.22, + health_status: "healthy", + last_success: new Date(Date.now() - 60000).toISOString(), // 1 min ago + last_failure: null, + by_method: { + search: createPluginMethodMetrics({ + method: "search", + requests_total: 520, + requests_success: 519, + requests_failed: 1, + avg_duration_ms: 115.3, + }), + get: createPluginMethodMetrics({ + method: "get", + requests_total: 370, + requests_success: 369, + requests_failed: 1, + avg_duration_ms: 140.2, + }), + }, + failure_counts: { + TIMEOUT: 2, + }, + }); + + const plugins = [mangabakaMetrics, comicvineMetrics, anilistMetrics]; + + // Calculate summary from plugins + const totalRequests = plugins.reduce((sum, p) => sum + p.requests_total, 0); + const totalSuccess = plugins.reduce( + (sum, p) => sum + p.requests_success, + 0, + ); + const totalFailed = plugins.reduce((sum, p) => sum + p.requests_failed, 0); + const totalRateLimitRejections = plugins.reduce( + (sum, p) => sum + p.rate_limit_rejections, + 0, + ); + + const response: PluginMetricsResponse = { + updated_at: new Date().toISOString(), + summary: { + total_plugins: plugins.length, + healthy_plugins: plugins.filter((p) => p.health_status === "healthy") + .length, + degraded_plugins: plugins.filter((p) => p.health_status === "degraded") + .length, + unhealthy_plugins: plugins.filter( + (p) => p.health_status === "unhealthy", + ).length, + total_requests: totalRequests, + total_success: totalSuccess, + total_failed: totalFailed, + total_rate_limit_rejections: totalRateLimitRejections, + }, + plugins, + }; + + return HttpResponse.json(response); + }), ]; diff --git a/web/src/mocks/handlers/pdfCache.ts b/web/src/mocks/handlers/pdfCache.ts index aea9e9a8..982ba173 100644 --- a/web/src/mocks/handlers/pdfCache.ts +++ b/web/src/mocks/handlers/pdfCache.ts @@ -3,9 +3,16 @@ */ import { delay, HttpResponse, http } from "msw"; +import type { components } from "@/types/api.generated"; + +type PdfCacheStatsDto = components["schemas"]["PdfCacheStatsDto"]; +type PdfCacheCleanupResultDto = + components["schemas"]["PdfCacheCleanupResultDto"]; +type TriggerPdfCacheCleanupResponse = + components["schemas"]["TriggerPdfCacheCleanupResponse"]; // Mock data for PDF cache stats -let mockPdfCacheStats = { +let mockPdfCacheStats: PdfCacheStatsDto = { total_files: 1500, total_size_bytes: 157_286_400, // ~150 MB total_size_human: "150.0 MB", @@ -26,18 +33,19 @@ export const pdfCacheHandlers = [ http.post("/api/v1/admin/pdf-cache/cleanup", async () => { await delay(200); - return HttpResponse.json({ + const response: TriggerPdfCacheCleanupResponse = { task_id: crypto.randomUUID(), message: "PDF cache cleanup task queued successfully", max_age_days: 30, - }); + }; + return HttpResponse.json(response); }), // Clear entire cache immediately (sync) http.delete("/api/v1/admin/pdf-cache", async () => { await delay(500); - const result = { + const result: PdfCacheCleanupResultDto = { files_deleted: mockPdfCacheStats.total_files, bytes_reclaimed: mockPdfCacheStats.total_size_bytes, bytes_reclaimed_human: mockPdfCacheStats.total_size_human, diff --git a/web/src/mocks/handlers/plugins.ts b/web/src/mocks/handlers/plugins.ts new file mode 100644 index 00000000..8831da94 --- /dev/null +++ b/web/src/mocks/handlers/plugins.ts @@ -0,0 +1,722 @@ +/** + * Plugin API mock handlers + * + * Provides mock data for: + * - Admin plugin management (CRUD, enable/disable, test, health) + * - Plugin actions (get actions, execute, metadata search) + * - Metadata preview/apply for series and books + */ + +import { delay, HttpResponse, http } from "msw"; +import type { components } from "@/types/api.generated"; + +type PluginDto = components["schemas"]["PluginDto"]; +type PluginSearchResultDto = components["schemas"]["PluginSearchResultDto"]; + +// Mock plugin data +const mockPlugins: PluginDto[] = [ + { + id: "plugin-mangabaka", + name: "mangabaka", + displayName: "MangaBaka", + description: "Fetches manga metadata from MangaUpdates/Baka-Updates", + pluginType: "system", + command: "npx", + args: ["@codex/plugin-mangabaka"], + workingDirectory: null, + env: { LOG_LEVEL: "info" }, + permissions: [ + "metadata:read", + "metadata:write:title", + "metadata:write:summary", + "metadata:write:genres", + "metadata:write:tags", + "metadata:write:year", + "metadata:write:status", + ], + scopes: ["library:detail", "series:detail", "series:bulk"], + libraryIds: [], + credentialDelivery: "env", + hasCredentials: true, + config: { rate_limit: 60 }, + enabled: true, + healthStatus: "healthy", + failureCount: 0, + lastFailureAt: null, + lastSuccessAt: "2024-01-20T12:00:00Z", + disabledReason: null, + manifest: { + name: "mangabaka", + displayName: "MangaBaka", + version: "1.0.0", + protocolVersion: "1.0", + description: "Fetches manga metadata from MangaUpdates/Baka-Updates", + author: "Codex Team", + capabilities: { + metadataProvider: ["series"], + userSyncProvider: false, + }, + contentTypes: ["series"], + requiredCredentials: [ + { + key: "api_key", + label: "API Key", + required: true, + sensitive: true, + credentialType: "password", + }, + ], + scopes: ["series:detail", "series:bulk"], + }, + createdAt: "2024-01-15T00:00:00Z", + updatedAt: "2024-01-20T00:00:00Z", + }, + { + id: "plugin-comicvine", + name: "comicvine", + displayName: "ComicVine", + description: "Fetches comic metadata from ComicVine", + pluginType: "system", + command: "python", + args: ["-m", "comicvine_plugin"], + workingDirectory: null, + env: {}, + permissions: [ + "metadata:read", + "metadata:write:title", + "metadata:write:summary", + "metadata:write:genres", + "metadata:write:covers", + ], + scopes: ["series:detail"], + libraryIds: [], + credentialDelivery: "env", + hasCredentials: false, + config: {}, + enabled: false, + healthStatus: "unhealthy", + failureCount: 3, + lastFailureAt: "2024-01-18T15:30:00Z", + lastSuccessAt: "2024-01-17T10:00:00Z", + disabledReason: "Disabled after 3 failures in 3600 seconds", + manifest: null, + createdAt: "2024-01-10T00:00:00Z", + updatedAt: "2024-01-18T00:00:00Z", + }, +]; + +// Mock search results +const mockSearchResults: PluginSearchResultDto[] = [ + { + externalId: "mu-12345", + title: "One Piece", + alternateTitles: ["ワンピース", "Wan Pīsu"], + year: 1997, + coverUrl: "https://cdn.mangaupdates.com/covers/one-piece.jpg", + relevanceScore: 0.98, + preview: { + status: "Ongoing", + genres: ["Action", "Adventure", "Comedy", "Fantasy"], + rating: 9.2, + description: "Follows the adventures of Monkey D. Luffy...", + }, + }, + { + externalId: "mu-67890", + title: "One Piece: Strong World", + alternateTitles: ["One Piece Movie 10"], + year: 2009, + coverUrl: "https://cdn.mangaupdates.com/covers/one-piece-strong-world.jpg", + relevanceScore: 0.75, + preview: { + status: "Completed", + genres: ["Action", "Adventure"], + rating: 8.5, + description: "A movie adaptation...", + }, + }, + { + externalId: "mu-11111", + title: "One Punch Man", + alternateTitles: ["ワンパンマン", "Wanpanman"], + year: 2012, + coverUrl: "https://cdn.mangaupdates.com/covers/one-punch-man.jpg", + relevanceScore: 0.65, + preview: { + status: "Ongoing", + genres: ["Action", "Comedy", "Superhero"], + rating: 9.0, + description: "The story of Saitama...", + }, + }, +]; + +// Mock metadata preview response +const createMockPreview = (externalId: string) => ({ + fields: [ + { + field: "title", + currentValue: "One Piece", + proposedValue: "ONE PIECE", + status: "will_apply", + }, + { + field: "summary", + currentValue: "An epic pirate adventure...", + proposedValue: + "Gol D. Roger was known as the Pirate King, the strongest and most infamous...", + status: "will_apply", + }, + { + field: "year", + currentValue: 1997, + proposedValue: 1997, + status: "unchanged", + }, + { + field: "status", + currentValue: "ongoing", + proposedValue: "ongoing", + status: "unchanged", + }, + { + field: "genres", + currentValue: ["Action", "Adventure"], + proposedValue: ["Action", "Adventure", "Comedy", "Fantasy"], + status: "will_apply", + }, + { + field: "tags", + currentValue: ["pirates"], + proposedValue: ["pirates", "treasure", "world-building", "friendship"], + status: "locked", + reason: "Field is locked by user", + }, + { + field: "publisher", + currentValue: "Shueisha", + proposedValue: "Shueisha Inc.", + status: "no_permission", + reason: "Plugin lacks metadata:write:publisher permission", + }, + { + field: "language", + currentValue: null, + proposedValue: null, + status: "not_provided", + }, + ], + summary: { + willApply: 3, + locked: 1, + noPermission: 1, + unchanged: 2, + notProvided: 1, + }, + pluginId: "plugin-mangabaka", + pluginName: "MangaBaka", + externalId, + externalUrl: `https://www.mangaupdates.com/series/${externalId}`, +}); + +export const pluginsHandlers = [ + // ============================================ + // Admin Plugin Management + // ============================================ + + // List all plugins (admin) + http.get("/api/v1/admin/plugins", async () => { + await delay(150); + return HttpResponse.json({ + plugins: mockPlugins, + total: mockPlugins.length, + }); + }), + + // Get single plugin (admin) + http.get("/api/v1/admin/plugins/:id", async ({ params }) => { + await delay(100); + const plugin = mockPlugins.find((p) => p.id === params.id); + if (!plugin) { + return HttpResponse.json({ error: "Plugin not found" }, { status: 404 }); + } + return HttpResponse.json(plugin); + }), + + // Create plugin (admin) + http.post("/api/v1/admin/plugins", async ({ request }) => { + await delay(200); + const body = (await request.json()) as Record; + const newPlugin = { + id: `plugin-${Date.now()}`, + ...body, + failureCount: 0, + disabledReason: null, + manifest: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + return HttpResponse.json(newPlugin, { status: 201 }); + }), + + // Update plugin (admin) + http.patch("/api/v1/admin/plugins/:id", async ({ params, request }) => { + await delay(150); + const plugin = mockPlugins.find((p) => p.id === params.id); + if (!plugin) { + return HttpResponse.json({ error: "Plugin not found" }, { status: 404 }); + } + const body = (await request.json()) as Record; + const updated = { ...plugin, ...body, updatedAt: new Date().toISOString() }; + return HttpResponse.json(updated); + }), + + // Delete plugin (admin) + http.delete("/api/v1/admin/plugins/:id", async ({ params }) => { + await delay(100); + const plugin = mockPlugins.find((p) => p.id === params.id); + if (!plugin) { + return HttpResponse.json({ error: "Plugin not found" }, { status: 404 }); + } + return new HttpResponse(null, { status: 204 }); + }), + + // Enable plugin (admin) + http.post("/api/v1/admin/plugins/:id/enable", async ({ params }) => { + await delay(150); + const pluginIndex = mockPlugins.findIndex((p) => p.id === params.id); + if (pluginIndex === -1) { + return HttpResponse.json({ error: "Plugin not found" }, { status: 404 }); + } + // Update the mock state + mockPlugins[pluginIndex] = { + ...mockPlugins[pluginIndex], + enabled: true, + healthStatus: "unknown", + disabledReason: null, + updatedAt: new Date().toISOString(), + }; + return HttpResponse.json({ + plugin: mockPlugins[pluginIndex], + message: "Plugin enabled successfully", + }); + }), + + // Disable plugin (admin) + http.post("/api/v1/admin/plugins/:id/disable", async ({ params }) => { + await delay(150); + const pluginIndex = mockPlugins.findIndex((p) => p.id === params.id); + if (pluginIndex === -1) { + return HttpResponse.json({ error: "Plugin not found" }, { status: 404 }); + } + // Update the mock state + mockPlugins[pluginIndex] = { + ...mockPlugins[pluginIndex], + enabled: false, + healthStatus: "disabled", + updatedAt: new Date().toISOString(), + }; + return HttpResponse.json({ + plugin: mockPlugins[pluginIndex], + message: "Plugin disabled successfully", + }); + }), + + // Test plugin (admin) + http.post("/api/v1/admin/plugins/:id/test", async ({ params }) => { + await delay(500); // Simulate plugin spawn time + const plugin = mockPlugins.find((p) => p.id === params.id); + if (!plugin) { + return HttpResponse.json({ error: "Plugin not found" }, { status: 404 }); + } + return HttpResponse.json({ + success: true, + manifest: plugin.manifest, + message: "Plugin test successful", + latencyMs: 450, + }); + }), + + // Get plugin health (admin) + http.get("/api/v1/admin/plugins/:id/health", async ({ params }) => { + await delay(100); + const plugin = mockPlugins.find((p) => p.id === params.id); + if (!plugin) { + return HttpResponse.json({ error: "Plugin not found" }, { status: 404 }); + } + return HttpResponse.json({ + pluginId: plugin.id, + status: plugin.enabled ? "healthy" : "disabled", + failureCount: plugin.failureCount, + disabledReason: plugin.disabledReason, + lastCheckedAt: new Date().toISOString(), + }); + }), + + // Reset plugin failures (admin) + http.post("/api/v1/admin/plugins/:id/reset", async ({ params }) => { + await delay(100); + const pluginIndex = mockPlugins.findIndex((p) => p.id === params.id); + if (pluginIndex === -1) { + return HttpResponse.json({ error: "Plugin not found" }, { status: 404 }); + } + // Update the mock state + mockPlugins[pluginIndex] = { + ...mockPlugins[pluginIndex], + failureCount: 0, + disabledReason: null, + healthStatus: mockPlugins[pluginIndex].enabled ? "unknown" : "disabled", + updatedAt: new Date().toISOString(), + }; + return HttpResponse.json({ + plugin: mockPlugins[pluginIndex], + message: "Plugin failures reset successfully", + }); + }), + + // Get plugin failures (admin) + http.get( + "/api/v1/admin/plugins/:id/failures", + async ({ params, request }) => { + await delay(100); + const plugin = mockPlugins.find((p) => p.id === params.id); + if (!plugin) { + return HttpResponse.json( + { error: "Plugin not found" }, + { status: 404 }, + ); + } + + const url = new URL(request.url); + const limit = Number.parseInt(url.searchParams.get("limit") || "20", 10); + + // Generate mock failures for the unhealthy plugin + const failures = + plugin.healthStatus === "unhealthy" || plugin.failureCount > 0 + ? [ + { + id: "failure-1", + errorMessage: "Connection timeout after 30s", + errorCode: "TIMEOUT", + method: "metadata/series/search", + context: { query: "One Piece" }, + occurredAt: "2024-01-18T15:30:00Z", + }, + { + id: "failure-2", + errorMessage: "Rate limited by provider", + errorCode: "RATE_LIMITED", + method: "metadata/series/search", + context: { query: "Naruto", retryAfterSeconds: 60 }, + occurredAt: "2024-01-18T15:25:00Z", + }, + { + id: "failure-3", + errorMessage: "API key is invalid or expired", + errorCode: "AUTH_FAILED", + method: "initialize", + context: null, + occurredAt: "2024-01-18T15:20:00Z", + }, + ].slice(0, limit) + : []; + + return HttpResponse.json({ + failures, + total: failures.length, + windowFailures: plugin.failureCount, + windowSeconds: 3600, + threshold: 3, + }); + }, + ), + + // ============================================ + // Plugin Actions (User-facing) + // ============================================ + + // Get available plugin actions for a scope + http.get("/api/v1/plugins/actions", async ({ request }) => { + await delay(100); + const url = new URL(request.url); + const scope = url.searchParams.get("scope"); + + // Filter plugins by scope and enabled status + const enabledPlugins = mockPlugins.filter( + (p) => p.enabled && p.scopes.includes(scope || ""), + ); + + const actions = enabledPlugins.map((plugin) => ({ + pluginId: plugin.id, + pluginName: plugin.name, + pluginDisplayName: plugin.displayName, + actionType: "metadata_search", + label: `Search ${plugin.displayName}`, + description: plugin.manifest?.description, + icon: null, + })); + + return HttpResponse.json({ + actions, + scope, + }); + }), + + // Execute plugin action + http.post("/api/v1/plugins/:id/execute", async ({ params, request }) => { + await delay(300); + const plugin = mockPlugins.find((p) => p.id === params.id); + if (!plugin) { + return HttpResponse.json({ error: "Plugin not found" }, { status: 404 }); + } + if (!plugin.enabled) { + return HttpResponse.json( + { error: "Plugin is disabled" }, + { status: 400 }, + ); + } + + const body = (await request.json()) as { + action: + | { + metadata: { + action: "search" | "get" | "match"; + content_type: "series"; + params: Record; + }; + } + | "ping"; + }; + + // Handle ping action + if (body.action === "ping") { + return HttpResponse.json({ + success: true, + result: "pong", + latencyMs: 50, + }); + } + + // Handle metadata actions + if (typeof body.action === "object" && "metadata" in body.action) { + const { + action, + content_type, + params: actionParams, + } = body.action.metadata; + + // Handle search action + if (action === "search" && content_type === "series") { + const query = (actionParams.query as string) || ""; + const results = mockSearchResults.filter( + (r) => + r.title.toLowerCase().includes(query.toLowerCase()) || + r.alternateTitles?.some((t) => + t.toLowerCase().includes(query.toLowerCase()), + ), + ); + return HttpResponse.json({ + success: true, + result: { results }, + latencyMs: 280, + }); + } + + // Handle get action + if (action === "get" && content_type === "series") { + const externalId = actionParams.externalId as string; + const result = mockSearchResults.find( + (r) => r.externalId === externalId, + ); + if (!result) { + return HttpResponse.json({ + success: false, + error: "External ID not found", + latencyMs: 100, + }); + } + return HttpResponse.json({ + success: true, + result: { + title: result.title, + alternateTitles: result.alternateTitles, + year: result.year, + status: result.preview?.status?.toLowerCase(), + genres: result.preview?.genres, + summary: result.preview?.description, + coverUrl: result.coverUrl, + }, + latencyMs: 250, + }); + } + + return HttpResponse.json({ + success: false, + error: `Unknown metadata action: ${action}`, + latencyMs: 50, + }); + } + + return HttpResponse.json({ + success: false, + error: "Unknown action format", + latencyMs: 50, + }); + }), + + // ============================================ + // Metadata Preview/Apply + // ============================================ + + // Preview series metadata + http.post( + "/api/v1/series/:seriesId/metadata/preview", + async ({ request }) => { + await delay(400); + const body = (await request.json()) as { + pluginId: string; + externalId: string; + }; + return HttpResponse.json(createMockPreview(body.externalId)); + }, + ), + + // Apply series metadata + http.post("/api/v1/series/:seriesId/metadata/apply", async ({ request }) => { + await delay(300); + const body = (await request.json()) as { + pluginId: string; + externalId: string; + fields?: string[]; + }; + + // Simulate applying only writable fields + const appliedFields = body.fields || ["title", "summary", "genres"]; + const skippedFields = [ + { field: "tags", reason: "Field is locked" }, + { field: "publisher", reason: "No permission" }, + ]; + + return HttpResponse.json({ + success: true, + appliedFields, + skippedFields, + message: `Applied ${appliedFields.length} fields from plugin`, + }); + }), + + // Preview book metadata + http.post("/api/v1/books/:bookId/metadata/preview", async ({ request }) => { + await delay(400); + const body = (await request.json()) as { + pluginId: string; + externalId: string; + }; + // Reuse series preview for simplicity + return HttpResponse.json(createMockPreview(body.externalId)); + }), + + // Apply book metadata + http.post("/api/v1/books/:bookId/metadata/apply", async ({ request }) => { + await delay(300); + const body = (await request.json()) as { + pluginId: string; + externalId: string; + fields?: string[]; + }; + + const appliedFields = body.fields || ["title", "summary"]; + return HttpResponse.json({ + success: true, + appliedFields, + skippedFields: [], + message: `Applied ${appliedFields.length} fields from plugin`, + }); + }), + + // Auto-match series metadata + http.post( + "/api/v1/series/:seriesId/metadata/auto-match", + async ({ request }) => { + await delay(600); // Simulate search + fetch + apply + // Parse body to validate request format + await request.json(); + + // Simulate finding a match + const bestMatch = mockSearchResults[0]; // Use first result as best match + const appliedFields = ["title", "summary", "genres", "year", "status"]; + const skippedFields = [ + { field: "tags", reason: "Field is locked" }, + { field: "publisher", reason: "No permission" }, + ]; + + return HttpResponse.json({ + success: true, + matchedResult: bestMatch, + appliedFields, + skippedFields, + message: `Matched '${bestMatch.title}' and applied ${appliedFields.length} field(s)`, + externalUrl: `https://www.mangaupdates.com/series/${bestMatch.externalId}`, + }); + }, + ), + + // Enqueue auto-match task for a single series + http.post( + "/api/v1/series/:seriesId/metadata/auto-match/task", + async ({ params }) => { + await delay(100); + const taskId = `task-${Date.now()}`; + return HttpResponse.json({ + success: true, + tasksEnqueued: 1, + taskIds: [taskId], + message: `Enqueued auto-match task for series ${params.seriesId}`, + }); + }, + ), + + // Bulk enqueue auto-match tasks for multiple series + http.post( + "/api/v1/series/metadata/auto-match/task/bulk", + async ({ request }) => { + await delay(200); + const body = (await request.json()) as { + pluginId: string; + seriesIds: string[]; + }; + const taskIds = body.seriesIds.map( + (_, i) => `task-bulk-${Date.now()}-${i}`, + ); + return HttpResponse.json({ + success: true, + tasksEnqueued: body.seriesIds.length, + taskIds, + message: `Enqueued ${body.seriesIds.length} auto-match task(s)`, + }); + }, + ), + + // Enqueue auto-match tasks for all series in a library + http.post( + "/api/v1/libraries/:libraryId/metadata/auto-match/task", + async () => { + await delay(300); + // Simulate enqueueing tasks for 10 series + const taskIds = Array.from( + { length: 10 }, + (_, i) => `task-lib-${Date.now()}-${i}`, + ); + return HttpResponse.json({ + success: true, + tasksEnqueued: 10, + taskIds, + message: "Enqueued 10 auto-match task(s) for library", + }); + }, + ), +]; + +// Export mock data for testing +export const getMockPlugins = () => [...mockPlugins]; +export const getMockSearchResults = () => [...mockSearchResults]; diff --git a/web/src/mocks/handlers/series.ts b/web/src/mocks/handlers/series.ts index 5f9edfed..1d8983d0 100644 --- a/web/src/mocks/handlers/series.ts +++ b/web/src/mocks/handlers/series.ts @@ -3,6 +3,7 @@ */ import { delay, HttpResponse, http } from "msw"; +import type { components } from "@/types/api.generated"; import { COMPLETED_COMIC, EXTERNAL_LINKS_METADATA, @@ -13,6 +14,8 @@ import { createPaginatedResponse, seriesSummaries } from "../data/factories"; import { getSeriesByLibrary, mockSeries } from "../data/store"; import coverSvg from "../fixtures/cover.svg?raw"; +type ExternalRatingDto = components["schemas"]["ExternalRatingDto"]; + /** * Sample custom metadata for specific series (by title match) * These demonstrate the custom metadata feature in development mode @@ -25,34 +28,16 @@ const SERIES_CUSTOM_METADATA: Record | null> = { // All other series will have null (no custom metadata) }; -/** - * External ratings from sources like MAL, AniList, etc. - * Ratings are stored on 0-100 scale, displayed as 0-10 - */ -interface MockExternalRating { - id: string; - seriesId: string; - sourceName: string; - rating: number; // 0-100 scale - voteCount: number | null; - fetchedAt: string; - createdAt: string; - updatedAt: string; -} - function getExternalRatingsForSeries( seriesId: string, title: string, -): MockExternalRating[] { +): ExternalRatingDto[] { const now = new Date().toISOString(); // Define external ratings for popular series const externalRatingsData: Record< string, - Omit< - MockExternalRating, - "id" | "seriesId" | "fetchedAt" | "createdAt" | "updatedAt" - >[] + { sourceName: string; rating: number; voteCount: number }[] > = { "One Piece": [ { sourceName: "myanimelist", rating: 90, voteCount: 450000 }, diff --git a/web/src/mocks/handlers/sharingTags.ts b/web/src/mocks/handlers/sharingTags.ts index 434a3ac1..27e6b480 100644 --- a/web/src/mocks/handlers/sharingTags.ts +++ b/web/src/mocks/handlers/sharingTags.ts @@ -3,23 +3,17 @@ */ import { delay, HttpResponse, http } from "msw"; +import type { components } from "@/types/api.generated"; import { createPaginatedResponse } from "../data/factories"; +type SharingTagDto = components["schemas"]["SharingTagDto"]; +type UserSharingTagGrantDto = components["schemas"]["UserSharingTagGrantDto"]; + // Mock sharing tags -const mockSharingTags: Array<{ - id: string; - name: string; - normalizedName: string; - description: string | null; - seriesCount: number; - userCount: number; - createdAt: string; - updatedAt: string; -}> = [ +const mockSharingTags: SharingTagDto[] = [ { id: "tag-kids", name: "Kids", - normalizedName: "kids", description: "Content appropriate for children", seriesCount: 5, userCount: 2, @@ -29,7 +23,6 @@ const mockSharingTags: Array<{ { id: "tag-mature", name: "Mature", - normalizedName: "mature", description: "Adult content", seriesCount: 3, userCount: 1, @@ -42,16 +35,7 @@ const mockSharingTags: Array<{ const mockSeriesSharingTags: Record = {}; // Mock user sharing tag grants (user_id -> grants) -const mockUserGrants: Record< - string, - Array<{ - id: string; - sharingTagId: string; - sharingTagName: string; - accessMode: "allow" | "deny"; - createdAt: string; - }> -> = {}; +const mockUserGrants: Record = {}; export const sharingTagsHandlers = [ // ============================================ @@ -104,10 +88,9 @@ export const sharingTagsHandlers = [ description?: string; }; - const newTag = { + const newTag: SharingTagDto = { id: `tag-${Date.now()}`, name: body.name, - normalizedName: body.name.toLowerCase(), description: body.description || null, seriesCount: 0, userCount: 0, @@ -136,7 +119,6 @@ export const sharingTagsHandlers = [ if (body.name !== undefined) { mockSharingTags[tagIndex].name = body.name; - mockSharingTags[tagIndex].normalizedName = body.name.toLowerCase(); } if (body.description !== undefined) { mockSharingTags[tagIndex].description = body.description; diff --git a/web/src/pages/BookDetail.tsx b/web/src/pages/BookDetail.tsx index a01c406c..97f165bc 100644 --- a/web/src/pages/BookDetail.tsx +++ b/web/src/pages/BookDetail.tsx @@ -31,12 +31,14 @@ import { IconDownload, IconEdit, IconEyeOff, + IconInfoCircle, IconPhoto, IconTrash, } from "@tabler/icons-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link, useNavigate, useParams } from "react-router-dom"; import { booksApi } from "@/api/books"; +import { BookInfoModal } from "@/components/book"; import { BookMetadataEditModal } from "@/components/books/BookMetadataEditModal"; import { usePermissions } from "@/hooks/usePermissions"; import { PERMISSIONS } from "@/types/permissions"; @@ -75,6 +77,8 @@ export function BookDetail() { const [summaryOpened, { toggle: toggleSummary }] = useDisclosure(false); const [editModalOpened, { open: openEditModal, close: closeEditModal }] = useDisclosure(false); + const [infoModalOpened, { open: openInfoModal, close: closeInfoModal }] = + useDisclosure(false); const isWideScreen = useMediaQuery("(min-width: 768px)"); // Permission checks @@ -407,14 +411,14 @@ export function BookDetail() { onClick={() => analyzeMutation.mutate()} disabled={analyzeMutation.isPending} > - Force Analyze + Analyze Book } onClick={() => generateThumbnailMutation.mutate()} disabled={generateThumbnailMutation.isPending} > - Generate Thumbnail + Regenerate Thumbnail Download + + + + + {/* Summary - show preview with expand if long */} @@ -716,6 +729,13 @@ export function BookDetail() { bookId={book.id} bookTitle={book.title} /> + + {/* Book Info Modal */} + ); } diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index a1217065..2bcf7cf1 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -1,4 +1,5 @@ import { Box, Stack, Title } from "@mantine/core"; +import { BulkSelectionToolbar } from "@/components/library/BulkSelectionToolbar"; import { RecommendedSection } from "@/components/library/RecommendedSection"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; @@ -9,6 +10,8 @@ export function Home() { Home + {/* Bulk Selection Toolbar - shows when items are selected */} + diff --git a/web/src/pages/Library.tsx b/web/src/pages/Library.tsx index 8a2c0931..75eeaec5 100644 --- a/web/src/pages/Library.tsx +++ b/web/src/pages/Library.tsx @@ -17,10 +17,12 @@ import { notifications } from "@mantine/notifications"; import { IconDotsVertical, IconEdit, + IconPhoto, IconRadar, IconScan, IconTrash, IconTrashX, + IconWand, } from "@tabler/icons-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; @@ -32,14 +34,21 @@ import { useSearchParams, } from "react-router-dom"; import { librariesApi } from "@/api/libraries"; +import { + type PluginActionDto, + pluginActionsApi, + pluginsApi, +} from "@/api/plugins"; import { LibraryModal } from "@/components/forms/LibraryModal"; import { BooksSection } from "@/components/library/BooksSection"; +import { BulkSelectionToolbar } from "@/components/library/BulkSelectionToolbar"; import { LibraryToolbar } from "@/components/library/LibraryToolbar"; import { RecommendedSection } from "@/components/library/RecommendedSection"; import { SeriesSection } from "@/components/library/SeriesSection"; import { useDynamicDocumentTitle } from "@/hooks/useDocumentTitle"; import { usePermissions } from "@/hooks/usePermissions"; import { useTaskProgress } from "@/hooks/useTaskProgress"; +import { useBulkSelectionStore } from "@/store/bulkSelectionStore"; import { useLibraryPreferencesHydrated, useLibraryPreferencesStore, @@ -92,6 +101,7 @@ export function LibraryPage() { const { hasPermission } = usePermissions(); const canEditLibrary = hasPermission(PERMISSIONS.LIBRARIES_WRITE); const canDeleteLibrary = hasPermission(PERMISSIONS.LIBRARIES_DELETE); + const canWriteTasks = hasPermission(PERMISSIONS.TASKS_WRITE); // Get active tasks for progress display const { getTasksByLibrary } = useTaskProgress(); @@ -112,6 +122,14 @@ export function LibraryPage() { } }, [currentTab]); + // Clear bulk selection when navigating away or changing tabs + const clearSelection = useBulkSelectionStore((state) => state.clearSelection); + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally clear on library/tab change + useEffect(() => { + // Clear selection when library or tab changes + clearSelection(); + }, [libraryId, currentTab]); + // Fetch library data (if not "all") const { data: library, @@ -264,6 +282,137 @@ export function LibraryPage() { }, }); + // Generate missing thumbnails mutation + const generateMissingThumbnailsMutation = useMutation({ + mutationFn: (libraryId: string) => + librariesApi.generateMissingThumbnails(libraryId), + onSuccess: () => { + notifications.show({ + title: "Thumbnail generation started", + message: "Missing thumbnails are being generated", + color: "blue", + }); + }, + onError: (error: Error) => { + notifications.show({ + title: "Thumbnail generation failed", + message: error.message || "Failed to start thumbnail generation", + color: "red", + }); + }, + }); + + // Regenerate all thumbnails mutation (force) + const regenerateAllThumbnailsMutation = useMutation({ + mutationFn: (libraryId: string) => + librariesApi.regenerateAllThumbnails(libraryId), + onSuccess: () => { + notifications.show({ + title: "Thumbnail regeneration started", + message: "All book thumbnails are being regenerated", + color: "blue", + }); + }, + onError: (error: Error) => { + notifications.show({ + title: "Thumbnail regeneration failed", + message: error.message || "Failed to start thumbnail regeneration", + color: "red", + }); + }, + }); + + // Generate missing series thumbnails mutation + const generateMissingSeriesThumbnailsMutation = useMutation({ + mutationFn: (libraryId: string) => + librariesApi.generateMissingSeriesThumbnails(libraryId), + onSuccess: () => { + notifications.show({ + title: "Series thumbnail generation started", + message: "Missing series thumbnails are being generated", + color: "blue", + }); + }, + onError: (error: Error) => { + notifications.show({ + title: "Series thumbnail generation failed", + message: error.message || "Failed to start series thumbnail generation", + color: "red", + }); + }, + }); + + // Regenerate all series thumbnails mutation (force) + const regenerateAllSeriesThumbnailsMutation = useMutation({ + mutationFn: (libraryId: string) => + librariesApi.regenerateAllSeriesThumbnails(libraryId), + onSuccess: () => { + notifications.show({ + title: "Series thumbnail regeneration started", + message: "All series thumbnails are being regenerated", + color: "blue", + }); + }, + onError: (error: Error) => { + notifications.show({ + title: "Series thumbnail regeneration failed", + message: + error.message || "Failed to start series thumbnail regeneration", + color: "red", + }); + }, + }); + + // Fetch available plugin actions for library:detail scope + const { data: pluginActions } = useQuery({ + queryKey: ["plugin-actions", "library:detail"], + queryFn: () => pluginsApi.getActions("library:detail"), + staleTime: 5 * 60 * 1000, // Cache for 5 minutes + enabled: canEditLibrary && !isAllLibraries, // Only fetch if user can edit libraries and not on "all" view + }); + + // Auto-match mutation for library-wide metadata matching + const autoMatchMutation = useMutation({ + mutationFn: ({ + libraryId, + pluginId, + }: { + libraryId: string; + pluginId: string; + }) => pluginActionsApi.enqueueLibraryAutoMatchTasks(libraryId, pluginId), + onSuccess: (data) => { + if (data.success) { + notifications.show({ + title: "Auto-match started", + message: data.message, + color: "blue", + }); + } else { + notifications.show({ + title: "Auto-match", + message: data.message, + color: "yellow", + }); + } + }, + onError: (error: Error) => { + notifications.show({ + title: "Auto-match failed", + message: error.message || "Failed to start auto-match", + color: "red", + }); + }, + }); + + // Handler for library auto-match action + const handleLibraryAutoMatch = (plugin: PluginActionDto) => { + if (!library) return; + autoMatchMutation.mutate({ + libraryId: library.id, + pluginId: plugin.pluginId, + }); + }; + // Tab navigation const handleTabChange = (value: string | null) => { if (value && libraryId) { @@ -527,6 +676,100 @@ export function LibraryPage() { > Edit Library + {canWriteTasks && ( + <> + + Book Thumbnails + } + onClick={() => + generateMissingThumbnailsMutation.mutate( + library.id, + ) + } + disabled={ + generateMissingThumbnailsMutation.isPending + } + > + Generate Missing + + } + onClick={() => + regenerateAllThumbnailsMutation.mutate( + library.id, + ) + } + disabled={ + regenerateAllThumbnailsMutation.isPending + } + > + Regenerate All + + + Series Thumbnails + } + onClick={() => + generateMissingSeriesThumbnailsMutation.mutate( + library.id, + ) + } + disabled={ + generateMissingSeriesThumbnailsMutation.isPending + } + > + Generate Missing + + } + onClick={() => + regenerateAllSeriesThumbnailsMutation.mutate( + library.id, + ) + } + disabled={ + regenerateAllSeriesThumbnailsMutation.isPending + } + > + Regenerate All + + + )} + {/* Plugin actions for library-wide auto-match */} + {(() => { + // Filter plugin actions to only show those that apply to this library + // Empty libraryIds means plugin applies to all libraries + const libraryPluginActions = + pluginActions?.actions.filter((action) => { + const libIds = action.libraryIds ?? []; + return ( + libIds.length === 0 || + libIds.includes(library.id) + ); + }) ?? []; + + return ( + libraryPluginActions.length > 0 && ( + <> + + Auto-Apply Metadata + {libraryPluginActions.map((action) => ( + } + onClick={() => + handleLibraryAutoMatch(action) + } + disabled={autoMatchMutation.isPending} + > + {action.pluginDisplayName} + + ))} + + ) + ); + })()} )} {(canEditLibrary || canDeleteLibrary) && ( @@ -606,6 +849,9 @@ export function LibraryPage() { } }} /> + + {/* Bulk Selection Toolbar - shows when items are selected */} + diff --git a/web/src/pages/SeriesDetail.tsx b/web/src/pages/SeriesDetail.tsx index 2474cae1..f6cef568 100644 --- a/web/src/pages/SeriesDetail.tsx +++ b/web/src/pages/SeriesDetail.tsx @@ -27,13 +27,24 @@ import { IconDotsVertical, IconDownload, IconEdit, + IconInfoCircle, IconPhoto, + IconSearch, + IconWand, } from "@tabler/icons-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; +import { + type PluginActionDto, + pluginActionsApi, + pluginsApi, +} from "@/api/plugins"; import { seriesApi } from "@/api/series"; import { settingsApi } from "@/api/settings"; import { sharingTagsApi } from "@/api/sharingTags"; +import { BulkSelectionToolbar } from "@/components/library/BulkSelectionToolbar"; +import { MetadataApplyFlow } from "@/components/metadata"; import { AlternateTitles, CommunityRating, @@ -42,10 +53,12 @@ import { ExternalRatings, GenreTagChips, SeriesBookList, + SeriesInfoModal, SeriesMetadataEditModal, SeriesRating, } from "@/components/series"; import { usePermissions } from "@/hooks/usePermissions"; +import { useCoverUpdatesStore } from "@/store/coverUpdatesStore"; import { PERMISSIONS } from "@/types/permissions"; import { transformFullSeriesToMetadataForTemplate } from "@/utils/templateUtils"; @@ -76,6 +89,22 @@ export function SeriesDetail() { const [summaryOpened, { toggle: toggleSummary }] = useDisclosure(false); const [editModalOpened, { open: openEditModal, close: closeEditModal }] = useDisclosure(false); + const [infoModalOpened, { open: openInfoModal, close: closeInfoModal }] = + useDisclosure(false); + + // Get cover update timestamp for cache-busting (forces image reload when cover is regenerated via SSE) + const coverTimestamp = useCoverUpdatesStore((state) => + seriesId ? state.updates[seriesId] : undefined, + ); + + // Plugin metadata flow state + const [selectedPlugin, setSelectedPlugin] = useState( + null, + ); + const [ + metadataFlowOpened, + { open: openMetadataFlow, close: closeMetadataFlow }, + ] = useDisclosure(false); // Fetch full series data (includes metadata, genres, tags, etc.) const { @@ -102,6 +131,61 @@ export function SeriesDetail() { enabled: !!seriesId && isAdmin, }); + // Fetch available plugin actions for series:detail scope, filtered by library + const { data: pluginActions } = useQuery({ + queryKey: ["plugin-actions", "series:detail", series?.libraryId], + queryFn: () => pluginsApi.getActions("series:detail", series?.libraryId), + staleTime: 5 * 60 * 1000, // Cache for 5 minutes + enabled: canEditSeries && !!series, // Only fetch if user can edit series and series is loaded + }); + + // Handler for plugin action click (opens search modal) + const handlePluginAction = (plugin: PluginActionDto) => { + setSelectedPlugin(plugin); + openMetadataFlow(); + }; + + // Auto-match mutation + const autoMatchMutation = useMutation({ + mutationFn: (pluginId: string) => + pluginActionsApi.autoMatchSeriesMetadata(seriesId!, pluginId), + onSuccess: (data) => { + if (data.success) { + notifications.show({ + title: "Metadata Applied", + message: data.message, + color: "green", + icon: , + }); + queryClient.invalidateQueries({ queryKey: ["series", seriesId] }); + } else { + notifications.show({ + title: "No Match Found", + message: data.message, + color: "yellow", + }); + } + }, + onError: (error: Error) => { + notifications.show({ + title: "Auto-match Failed", + message: error.message, + color: "red", + }); + }, + }); + + // Handler for auto-match action + const handleAutoMatch = (plugin: PluginActionDto) => { + autoMatchMutation.mutate(plugin.pluginId); + }; + + // Handler for metadata apply success + const handleMetadataApplySuccess = () => { + // Refetch series data to show updated metadata + queryClient.invalidateQueries({ queryKey: ["series", seriesId] }); + }; + // Mark as read mutation const markAsReadMutation = useMutation({ mutationFn: () => seriesApi.markAsRead(seriesId!), @@ -202,13 +286,32 @@ export function SeriesDetail() { }, }); - // Generate thumbnails mutation (for all books) - const generateThumbnailsMutation = useMutation({ - mutationFn: () => seriesApi.generateThumbnails(seriesId!), + // Generate missing book thumbnails mutation + const generateMissingBookThumbnailsMutation = useMutation({ + mutationFn: () => seriesApi.generateMissingBookThumbnails(seriesId!), + onSuccess: () => { + notifications.show({ + title: "Thumbnail generation started", + message: "Missing book thumbnails queued for generation", + color: "blue", + }); + }, + onError: (error: Error) => { + notifications.show({ + title: "Failed", + message: error.message, + color: "red", + }); + }, + }); + + // Regenerate all book thumbnails mutation + const regenerateBookThumbnailsMutation = useMutation({ + mutationFn: () => seriesApi.regenerateBookThumbnails(seriesId!), onSuccess: () => { notifications.show({ - title: "Thumbnails generation started", - message: "All books queued for thumbnail generation", + title: "Thumbnail regeneration started", + message: "All books queued for thumbnail regeneration", color: "blue", }); }, @@ -221,6 +324,26 @@ export function SeriesDetail() { }, }); + // Generate series cover thumbnail if missing mutation + const generateSeriesThumbnailIfMissingMutation = useMutation({ + mutationFn: () => seriesApi.generateSeriesThumbnailIfMissing(seriesId!), + onSuccess: () => { + notifications.show({ + title: "Series cover generation started", + message: "Series cover thumbnail will be generated if missing", + color: "blue", + }); + queryClient.invalidateQueries({ queryKey: ["series", seriesId] }); + }, + onError: (error: Error) => { + notifications.show({ + title: "Failed", + message: error.message, + color: "red", + }); + }, + }); + // Regenerate series cover thumbnail mutation const regenerateSeriesThumbnailMutation = useMutation({ mutationFn: () => seriesApi.regenerateSeriesThumbnail(seriesId!), @@ -230,7 +353,6 @@ export function SeriesDetail() { message: "Series cover thumbnail will be regenerated", color: "blue", }); - // Invalidate series queries to refresh the cover queryClient.invalidateQueries({ queryKey: ["series", seriesId] }); }, onError: (error: Error) => { @@ -267,7 +389,9 @@ export function SeriesDetail() { ); } - const coverUrl = `/api/v1/series/${series.id}/thumbnail?v=${encodeURIComponent(series.updatedAt)}`; + // Use coverTimestamp from SSE events for cache-busting, fall back to series.updatedAt + const coverCacheBuster = coverTimestamp ?? series.updatedAt; + const coverUrl = `/api/v1/series/${series.id}/thumbnail?v=${encodeURIComponent(String(coverCacheBuster))}`; const hasUnread = (series.unreadCount ?? 0) > 0; const hasRead = (series.bookCount ?? 0) > (series.unreadCount ?? 0); // Access metadata fields from the nested metadata object @@ -366,7 +490,7 @@ export function SeriesDetail() { - + @@ -399,21 +523,49 @@ export function SeriesDetail() { onClick={() => analyzeMutation.mutate()} disabled={analyzeMutation.isPending} > - Analyze All + Analyze All Books } onClick={() => analyzeUnanalyzedMutation.mutate()} disabled={analyzeUnanalyzedMutation.isPending} > - Analyze Unanalyzed + Analyze Unanalyzed Books + + Book Thumbnails } - onClick={() => generateThumbnailsMutation.mutate()} - disabled={generateThumbnailsMutation.isPending} + onClick={() => + generateMissingBookThumbnailsMutation.mutate() + } + disabled={ + generateMissingBookThumbnailsMutation.isPending + } > - Generate Books Thumbnails + Generate Missing + + } + onClick={() => + regenerateBookThumbnailsMutation.mutate() + } + disabled={regenerateBookThumbnailsMutation.isPending} + > + Regenerate All + + + Series Thumbnail + } + onClick={() => + generateSeriesThumbnailIfMissingMutation.mutate() + } + disabled={ + generateSeriesThumbnailIfMissingMutation.isPending + } + > + Generate If Missing } @@ -422,7 +574,7 @@ export function SeriesDetail() { } disabled={regenerateSeriesThumbnailMutation.isPending} > - Generate Series Thumbnail + Regenerate Edit Metadata + {/* Plugin actions for metadata fetching */} + {pluginActions && pluginActions.actions.length > 0 && ( + <> + + Fetch Metadata + {pluginActions.actions.map((action) => ( + } + onClick={() => handlePluginAction(action)} + > + {action.label} + + ))} + + Auto-Apply Metadata + {pluginActions.actions.map((action) => ( + } + onClick={() => handleAutoMatch(action)} + disabled={autoMatchMutation.isPending} + > + {action.pluginDisplayName} + + ))} + + )} )} @@ -447,7 +627,7 @@ export function SeriesDetail() { )} - {/* Download button */} + {/* Action buttons */} + + + + + {/* Summary - show preview with expand if long */} @@ -598,6 +787,9 @@ export function SeriesDetail() { )} + {/* Bulk Selection Toolbar - shows when items are selected */} + + {/* Books list */} + + {/* Plugin Metadata Apply Flow */} + {selectedPlugin && ( + + )} + + {/* Series Info Modal */} + ); } diff --git a/web/src/pages/settings/MetricsSettings.tsx b/web/src/pages/settings/MetricsSettings.tsx index 4c36647f..1b970aa5 100644 --- a/web/src/pages/settings/MetricsSettings.tsx +++ b/web/src/pages/settings/MetricsSettings.tsx @@ -25,18 +25,24 @@ import { IconBook, IconBooks, IconChartBar, + IconCheck, IconChevronDown, IconChevronRight, + IconClock, IconDatabase, IconFolder, + IconPlugConnected, IconRefresh, IconTrash, IconUsers, + IconX, } from "@tabler/icons-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { LibraryMetricsDto, MetricsDto, + PluginMetricsDto, + PluginMetricsResponse, TaskMetricsResponse, TaskTypeMetricsDto, } from "@/api/metrics"; @@ -626,6 +632,408 @@ function TaskMetricsTab({ metrics }: { metrics: TaskMetricsResponse }) { ); } +// Plugin metrics row with expandable details +function PluginMetricsRow({ metrics }: { metrics: PluginMetricsDto }) { + const [opened, { toggle }] = useDisclosure(false); + const successRate = + metrics.requests_total > 0 + ? ( + ((metrics.requests_success ?? 0) / metrics.requests_total) * + 100 + ).toFixed(1) + : "0"; + + const healthColor = + metrics.health_status === "healthy" + ? "green" + : metrics.health_status === "degraded" + ? "yellow" + : metrics.health_status === "unhealthy" + ? "red" + : "gray"; + + return ( + <> + + + + {opened ? ( + + ) : ( + + )} + + {metrics.plugin_name} + + + + + {metrics.requests_total.toLocaleString()} + + + + = 95 + ? "green" + : Number.parseFloat(successRate) >= 80 + ? "yellow" + : "red" + } + size="sm" + w={60} + /> + {successRate}% + + + + {formatDuration(metrics.avg_duration_ms ?? 0)} + + + + {(metrics.rate_limit_rejections ?? 0).toLocaleString()} + + + + + {metrics.health_status} + + + + {opened && ( + + + + +
+ + Succeeded + + + {(metrics.requests_success ?? 0).toLocaleString()} + +
+
+ + Failed + + 0 ? "red" : undefined} + > + {(metrics.requests_failed ?? 0).toLocaleString()} + +
+
+ + Error Rate + + 10 + ? "red" + : (metrics.error_rate_pct ?? 0) > 5 + ? "yellow" + : undefined + } + > + {(metrics.error_rate_pct ?? 0).toFixed(2)}% + +
+
+ + Rate Limit Hits + + 0 + ? "yellow" + : undefined + } + > + {(metrics.rate_limit_rejections ?? 0).toLocaleString()} + +
+ {metrics.last_success && ( +
+ + Last Success + + + {new Date(metrics.last_success).toLocaleString()} + +
+ )} + {metrics.last_failure && ( +
+ + Last Failure + + + {new Date(metrics.last_failure).toLocaleString()} + +
+ )} +
+ + {/* Method breakdown */} + {metrics.by_method && + Object.keys(metrics.by_method).length > 0 && ( + + + By Method + + + {Object.entries(metrics.by_method).map( + ([method, methodMetrics]) => ( + + + + {method} + + + {methodMetrics.requests_total} calls + + + + + {methodMetrics.requests_success} ok + + {(methodMetrics.requests_failed ?? 0) > 0 && ( + + {methodMetrics.requests_failed} failed + + )} + + avg{" "} + {formatDuration(methodMetrics.avg_duration_ms)} + + + + ), + )} + + + )} + + {/* Failure breakdown */} + {metrics.failure_counts && + Object.keys(metrics.failure_counts).length > 0 && ( + + + Failures by Type + + + {Object.entries(metrics.failure_counts).map( + ([code, count]) => ( + + {code}: {count} + + ), + )} + + + )} +
+
+
+ )} + + ); +} + +// Plugin metrics tab content +function PluginMetricsTab({ metrics }: { metrics: PluginMetricsResponse }) { + const summary = metrics.summary; + const plugins = metrics.plugins ?? []; + + const successRate = + summary.total_requests > 0 + ? ((summary.total_success ?? 0) / summary.total_requests) * 100 + : 0; + + return ( + + {/* Summary cards */} + + + + Total Plugins + + + {summary.total_plugins} + + + + + Healthy + + + {summary.healthy_plugins} + + + + + Degraded + + 0 ? "yellow" : undefined} + > + {summary.degraded_plugins} + + + + + Unhealthy + + 0 ? "red" : undefined} + > + {summary.unhealthy_plugins} + + + + + Total Requests + + + {(summary.total_requests ?? 0).toLocaleString()} + + + + + Success Rate + + = 95 ? "green" : successRate >= 80 ? "yellow" : "red" + } + > + {successRate.toFixed(1)}% + + + + + {/* Additional stats */} + + + + +
+ + Successful Requests + + + {(summary.total_success ?? 0).toLocaleString()} + +
+
+
+ + + +
+ + Failed Requests + + 0 ? "red" : undefined} + > + {(summary.total_failed ?? 0).toLocaleString()} + +
+
+
+ + + +
+ + Rate Limit Rejections + + 0 + ? "yellow" + : undefined + } + > + {(summary.total_rate_limit_rejections ?? 0).toLocaleString()} + +
+
+
+
+ + {/* Plugin breakdown table */} + {plugins.length > 0 ? ( +
+ + Plugin Performance + + + + + Plugin + Requests + Success Rate + Avg Duration + Rate Limited + Health + + + + {[...plugins] + .sort((a, b) => a.plugin_name.localeCompare(b.plugin_name)) + .map((plugin) => ( + + ))} + +
+
+ ) : ( + +
+ + + + No plugin metrics available yet + + + Plugin metrics will appear here after plugins are used + + +
+
+ )} +
+ ); +} + export function MetricsSettings() { const queryClient = useQueryClient(); @@ -649,6 +1057,16 @@ export function MetricsSettings() { queryFn: metricsApi.getTaskMetrics, }); + // Fetch plugin metrics + const { + data: pluginMetrics, + isLoading: pluginLoading, + error: pluginError, + } = useQuery({ + queryKey: ["metrics", "plugins"], + queryFn: metricsApi.getPluginMetrics, + }); + // Cleanup mutation const cleanupMutation = useMutation({ mutationFn: metricsApi.cleanupTaskMetrics, @@ -673,7 +1091,7 @@ export function MetricsSettings() { queryClient.invalidateQueries({ queryKey: ["metrics"] }); }; - if (inventoryLoading || taskLoading) { + if (inventoryLoading || taskLoading || pluginLoading) { return (
@@ -681,7 +1099,7 @@ export function MetricsSettings() { ); } - if (inventoryError || taskError) { + if (inventoryError || taskError || pluginError) { return (
@@ -733,6 +1151,12 @@ export function MetricsSettings() { }> Task Performance + } + > + Plugins + @@ -742,6 +1166,10 @@ export function MetricsSettings() { {taskMetrics && } + + + {pluginMetrics && } + ); diff --git a/web/src/pages/settings/PluginsSettings.test.tsx b/web/src/pages/settings/PluginsSettings.test.tsx new file mode 100644 index 00000000..07c14c08 --- /dev/null +++ b/web/src/pages/settings/PluginsSettings.test.tsx @@ -0,0 +1,89 @@ +import { notifications } from "@mantine/notifications"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders, screen } from "@/test/utils"; +import { PluginsSettings } from "./PluginsSettings"; + +// Mock the APIs +vi.mock("@/api/plugins", () => ({ + AVAILABLE_PERMISSIONS: [ + { value: "series:read", label: "Series Read" }, + { value: "series:write", label: "Series Write" }, + ], + AVAILABLE_SCOPES: [ + { value: "series:detail", label: "Series Detail" }, + { value: "series:bulk", label: "Series Bulk" }, + ], + CREDENTIAL_DELIVERY_OPTIONS: [ + { value: "env", label: "Environment Variables" }, + { value: "stdin", label: "Standard Input" }, + ], + pluginsApi: { + list: vi.fn().mockResolvedValue([]), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getFailures: vi.fn().mockResolvedValue({ failures: [] }), + }, +})); + +vi.mock("@/api/libraries", () => ({ + librariesApi: { + getLibraries: vi.fn().mockResolvedValue([]), + }, +})); + +vi.mock("@mantine/notifications", () => ({ + notifications: { + show: vi.fn(), + }, +})); + +describe("PluginsSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders page title", () => { + renderWithProviders(); + expect(screen.getByText("Plugins")).toBeInTheDocument(); + }); + + it("shows add plugin button", () => { + renderWithProviders(); + expect(screen.getByText("Add Plugin")).toBeInTheDocument(); + }); + + it("shows page description", () => { + renderWithProviders(); + expect( + screen.getByText( + /Manage external plugin processes for metadata fetching/i, + ), + ).toBeInTheDocument(); + }); +}); + +// Test the safeJsonParse helper function behavior +describe("safeJsonParse notification behavior", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows error notification when notifications.show is called with invalid JSON message", () => { + // This tests that the notification infrastructure is properly mocked + // and can be used to verify safeJsonParse behavior in integration tests + notifications.show({ + title: "Invalid JSON", + message: + "The credentials field contains invalid JSON. Please check the format.", + color: "red", + }); + + expect(notifications.show).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Invalid JSON", + color: "red", + }), + ); + }); +}); diff --git a/web/src/pages/settings/PluginsSettings.tsx b/web/src/pages/settings/PluginsSettings.tsx new file mode 100644 index 00000000..203d37d1 --- /dev/null +++ b/web/src/pages/settings/PluginsSettings.tsx @@ -0,0 +1,1398 @@ +import { + ActionIcon, + Alert, + Badge, + Box, + Button, + Card, + Code, + Collapse, + Divider, + Group, + Loader, + Modal, + MultiSelect, + NumberInput, + ScrollArea, + Select, + Stack, + Switch, + Table, + Tabs, + Text, + Textarea, + TextInput, + Title, + Tooltip, +} from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { useDisclosure } from "@mantine/hooks"; +import { notifications } from "@mantine/notifications"; +import { + IconAlertCircle, + IconChevronDown, + IconChevronRight, + IconEdit, + IconPlayerPlay, + IconPlugConnected, + IconPlus, + IconRefresh, + IconTrash, +} from "@tabler/icons-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { librariesApi } from "@/api/libraries"; +import { + AVAILABLE_PERMISSIONS, + AVAILABLE_SCOPES, + CREDENTIAL_DELIVERY_OPTIONS, + type CreatePluginRequest, + type PluginDto, + type PluginFailuresResponse, + type PluginHealthStatus, + pluginsApi, +} from "@/api/plugins"; + +// Health status badge color mapping +const healthStatusColors: Record = { + unknown: "gray", + healthy: "green", + degraded: "yellow", + unhealthy: "orange", + disabled: "red", +}; + +// Plugin form values type +interface PluginFormValues { + name: string; + displayName: string; + description: string; + command: string; + args: string; + envVars: { key: string; value: string }[]; + workingDirectory: string; + permissions: string[]; + scopes: string[]; + allLibraries: boolean; + libraryIds: string[]; + credentialDelivery: string; + credentials: string; + config: string; + enabled: boolean; + rateLimitEnabled: boolean; + rateLimitRequestsPerMinute: number; +} + +const defaultFormValues: PluginFormValues = { + name: "", + displayName: "", + description: "", + command: "", + args: "", + envVars: [], + workingDirectory: "", + permissions: [], + scopes: [], + allLibraries: true, + libraryIds: [], + credentialDelivery: "env", + credentials: "", + config: "", + enabled: false, + rateLimitEnabled: true, + rateLimitRequestsPerMinute: 60, +}; + +// Normalize plugin name to slug format (lowercase alphanumeric with hyphens) +// Matches backend validation: lowercase alphanumeric and hyphens only +// Cannot start or end with a hyphen +function normalizePluginName(value: string): string { + return value + .toLowerCase() + .trim() + .replace(/[\s_]+/g, "-") // spaces and underscores -> hyphens + .replace(/-+/g, "-") // collapse multiple hyphens to single + .replace(/[^a-z0-9-]/g, "") // remove invalid chars + .replace(/^-+|-+$/g, ""); // trim leading/trailing hyphens +} + +/** + * Safely parse JSON with try-catch to handle malformed input. + * Returns undefined if parsing fails, showing an error notification to the user. + */ +function safeJsonParse( + jsonString: string, + fieldName: string, +): Record | undefined { + try { + return JSON.parse(jsonString); + } catch { + notifications.show({ + title: "Invalid JSON", + message: `The ${fieldName} field contains invalid JSON. Please check the format.`, + color: "red", + }); + return undefined; + } +} + +export function PluginsSettings() { + const queryClient = useQueryClient(); + const [ + createModalOpened, + { open: openCreateModal, close: closeCreateModal }, + ] = useDisclosure(false); + const [editModalOpened, { open: openEditModal, close: closeEditModal }] = + useDisclosure(false); + const [ + deleteModalOpened, + { open: openDeleteModal, close: closeDeleteModal }, + ] = useDisclosure(false); + const [selectedPlugin, setSelectedPlugin] = useState(null); + const [expandedRows, setExpandedRows] = useState>(new Set()); + + // Fetch plugins + const { + data: pluginsResponse, + isLoading, + error, + } = useQuery({ + queryKey: ["plugins"], + queryFn: pluginsApi.getAll, + }); + + const plugins = pluginsResponse?.plugins ?? []; + + // Fetch libraries for the library filter dropdown + const { data: libraries = [] } = useQuery({ + queryKey: ["libraries"], + queryFn: librariesApi.getAll, + }); + + // Create form + const createForm = useForm({ + initialValues: defaultFormValues, + validate: { + name: (value) => { + if (!value.trim()) return "Name is required"; + if (!/^[a-z0-9-]+$/.test(value)) { + return "Name must be lowercase alphanumeric with hyphens only"; + } + return null; + }, + displayName: (value) => + !value.trim() ? "Display name is required" : null, + command: (value) => (!value.trim() ? "Command is required" : null), + }, + }); + + // Edit form + const editForm = useForm({ + initialValues: defaultFormValues, + validate: { + displayName: (value) => + !value.trim() ? "Display name is required" : null, + command: (value) => (!value.trim() ? "Command is required" : null), + }, + }); + + // Mutations + const createMutation = useMutation({ + mutationFn: async (values: PluginFormValues) => { + const request: CreatePluginRequest = { + name: values.name.trim(), + displayName: values.displayName.trim(), + description: values.description.trim() || undefined, + command: values.command.trim(), + args: values.args + .split("\n") + .map((a) => a.trim()) + .filter(Boolean), + env: values.envVars.filter((e) => e.key.trim()), + workingDirectory: values.workingDirectory.trim() || undefined, + permissions: values.permissions, + scopes: values.scopes, + libraryIds: values.allLibraries ? [] : values.libraryIds, + credentialDelivery: values.credentialDelivery, + credentials: values.credentials.trim() + ? safeJsonParse(values.credentials, "credentials") + : undefined, + config: values.config.trim() + ? safeJsonParse(values.config, "config") + : undefined, + enabled: values.enabled, + rateLimitRequestsPerMinute: values.rateLimitEnabled + ? values.rateLimitRequestsPerMinute + : null, + }; + return pluginsApi.create(request); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["plugins"] }); + queryClient.invalidateQueries({ queryKey: ["plugin-actions"] }); + closeCreateModal(); + createForm.reset(); + notifications.show({ + title: "Success", + message: "Plugin created successfully", + color: "green", + }); + }, + onError: (error: Error) => { + notifications.show({ + title: "Error", + message: error.message || "Failed to create plugin", + color: "red", + }); + }, + }); + + const updateMutation = useMutation({ + mutationFn: async ({ + id, + values, + }: { + id: string; + values: PluginFormValues; + }) => { + return pluginsApi.update(id, { + displayName: values.displayName.trim(), + description: values.description.trim() || null, + command: values.command.trim(), + args: values.args + .split("\n") + .map((a) => a.trim()) + .filter(Boolean), + env: values.envVars.filter((e) => e.key.trim()), + workingDirectory: values.workingDirectory.trim() || null, + permissions: values.permissions, + scopes: values.scopes, + libraryIds: values.allLibraries ? [] : values.libraryIds, + credentialDelivery: values.credentialDelivery, + credentials: values.credentials.trim() + ? safeJsonParse(values.credentials, "credentials") + : undefined, + config: values.config.trim() + ? safeJsonParse(values.config, "config") + : undefined, + rateLimitRequestsPerMinute: values.rateLimitEnabled + ? values.rateLimitRequestsPerMinute + : null, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["plugins"] }); + queryClient.invalidateQueries({ queryKey: ["plugin-actions"] }); + closeEditModal(); + setSelectedPlugin(null); + notifications.show({ + title: "Success", + message: "Plugin updated successfully", + color: "green", + }); + }, + onError: (error: Error) => { + notifications.show({ + title: "Error", + message: error.message || "Failed to update plugin", + color: "red", + }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: pluginsApi.delete, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["plugins"] }); + queryClient.invalidateQueries({ queryKey: ["plugin-actions"] }); + closeDeleteModal(); + setSelectedPlugin(null); + notifications.show({ + title: "Success", + message: "Plugin deleted successfully", + color: "green", + }); + }, + onError: (error: Error) => { + notifications.show({ + title: "Error", + message: error.message || "Failed to delete plugin", + color: "red", + }); + }, + }); + + const enableMutation = useMutation({ + mutationFn: pluginsApi.enable, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ["plugins"] }); + queryClient.invalidateQueries({ queryKey: ["plugin-actions"] }); + notifications.show({ + title: "Success", + message: data.message, + color: "green", + }); + }, + onError: (error: Error) => { + notifications.show({ + title: "Error", + message: error.message || "Failed to enable plugin", + color: "red", + }); + }, + }); + + const disableMutation = useMutation({ + mutationFn: pluginsApi.disable, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ["plugins"] }); + queryClient.invalidateQueries({ queryKey: ["plugin-actions"] }); + notifications.show({ + title: "Success", + message: data.message, + color: "green", + }); + }, + onError: (error: Error) => { + notifications.show({ + title: "Error", + message: error.message || "Failed to disable plugin", + color: "red", + }); + }, + }); + + const testMutation = useMutation({ + mutationFn: pluginsApi.test, + onSuccess: (data) => { + if (data.success) { + notifications.show({ + title: "Connection Successful", + message: `${data.message}${data.latencyMs ? ` (${data.latencyMs}ms)` : ""}`, + color: "green", + }); + } else { + notifications.show({ + title: "Connection Failed", + message: data.message, + color: "red", + }); + } + queryClient.invalidateQueries({ queryKey: ["plugins"] }); + }, + onError: (error: Error) => { + notifications.show({ + title: "Test Failed", + message: error.message || "Failed to test plugin connection", + color: "red", + }); + }, + }); + + const resetFailuresMutation = useMutation({ + mutationFn: pluginsApi.resetFailures, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ["plugins"] }); + queryClient.invalidateQueries({ queryKey: ["plugin-actions"] }); + notifications.show({ + title: "Success", + message: data.message, + color: "green", + }); + }, + onError: (error: Error) => { + notifications.show({ + title: "Error", + message: error.message || "Failed to reset failures", + color: "red", + }); + }, + }); + + const handleEditPlugin = (plugin: PluginDto) => { + setSelectedPlugin(plugin); + editForm.setValues({ + name: plugin.name, + displayName: plugin.displayName, + description: plugin.description || "", + command: plugin.command, + args: plugin.args.join("\n"), + envVars: + typeof plugin.env === "object" && plugin.env !== null + ? Object.entries(plugin.env as Record).map( + ([key, value]) => ({ key, value }), + ) + : [], + workingDirectory: plugin.workingDirectory || "", + permissions: plugin.permissions, + scopes: plugin.scopes, + allLibraries: plugin.libraryIds.length === 0, + libraryIds: plugin.libraryIds, + credentialDelivery: plugin.credentialDelivery, + credentials: "", + config: + plugin.config && Object.keys(plugin.config as object).length > 0 + ? JSON.stringify(plugin.config, null, 2) + : "", + enabled: plugin.enabled, + rateLimitEnabled: plugin.rateLimitRequestsPerMinute != null, + rateLimitRequestsPerMinute: plugin.rateLimitRequestsPerMinute ?? 60, + }); + openEditModal(); + }; + + const handleDeletePlugin = (plugin: PluginDto) => { + setSelectedPlugin(plugin); + openDeleteModal(); + }; + + const toggleRowExpansion = (id: string) => { + setExpandedRows((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + return ( + + + +
+ Plugins + + Manage external plugin processes for metadata fetching and other + integrations + +
+ +
+ + {isLoading ? ( + + + + ) : error ? ( + } color="red"> + Failed to load plugins. Please try again. + + ) : plugins.length > 0 ? ( + + + + + + + Plugin + Command + Status + Health + Actions + + + + {plugins.map((plugin) => ( + <> + + + toggleRowExpansion(plugin.id)} + > + {expandedRows.has(plugin.id) ? ( + + ) : ( + + )} + + + + + +
+ {plugin.displayName} + + {plugin.name} + +
+
+
+ + {plugin.command} + + + + plugin.enabled + ? disableMutation.mutate(plugin.id) + : enableMutation.mutate(plugin.id) + } + disabled={ + enableMutation.isPending || + disableMutation.isPending + } + /> + + + + + {plugin.healthStatus} + + {plugin.failureCount > 0 && ( + + + {plugin.failureCount} + + + )} + + + + + + testMutation.mutate(plugin.id)} + loading={ + testMutation.isPending && + testMutation.variables === plugin.id + } + > + + + + {plugin.failureCount > 0 && ( + + + resetFailuresMutation.mutate(plugin.id) + } + loading={ + resetFailuresMutation.isPending && + resetFailuresMutation.variables === + plugin.id + } + > + + + + )} + + handleEditPlugin(plugin)} + > + + + + + handleDeletePlugin(plugin)} + > + + + + + +
+ + + + + + + + + + + ))} +
+
+
+
+ ) : ( + } + color="gray" + variant="light" + > + No plugins configured + + Add plugins to enable metadata fetching from external sources like + MangaBaka, AniList, or other providers. + + + )} +
+ + {/* Create Plugin Modal */} + { + closeCreateModal(); + createForm.reset(); + }} + title="Add Plugin" + size="lg" + > + createMutation.mutate(values)} + isLoading={createMutation.isPending} + onCancel={() => { + closeCreateModal(); + createForm.reset(); + }} + isCreate + libraries={libraries} + /> + + + {/* Edit Plugin Modal */} + { + closeEditModal(); + setSelectedPlugin(null); + }} + title={`Edit Plugin: ${selectedPlugin?.displayName}`} + size="lg" + > + + selectedPlugin && + updateMutation.mutate({ id: selectedPlugin.id, values }) + } + isLoading={updateMutation.isPending} + onCancel={() => { + closeEditModal(); + setSelectedPlugin(null); + }} + libraries={libraries} + /> + + + {/* Delete Plugin Modal */} + { + closeDeleteModal(); + setSelectedPlugin(null); + }} + title="Delete Plugin" + > + + + Are you sure you want to delete the plugin{" "} + {selectedPlugin?.displayName}? + + + This action cannot be undone. + + + + + + + +
+ ); +} + +// Plugin details component for expanded row +function PluginDetails({ + plugin, + libraries, +}: { + plugin: PluginDto; + libraries: { id: string; name: string }[]; +}) { + return ( + + +
+ + Description + + {plugin.description || "No description"} +
+
+ + Credentials + + + {plugin.hasCredentials ? "Configured" : "Not configured"} + +
+
+ + Delivery Method + + {plugin.credentialDelivery} +
+
+ + Rate Limit + + + {plugin.rateLimitRequestsPerMinute != null + ? `${plugin.rateLimitRequestsPerMinute} req/min` + : "No limit"} + +
+
+ + {plugin.args.length > 0 && ( +
+ + Arguments + + {plugin.args.join("\n")} +
+ )} + + +
+ + Permissions + + + {plugin.permissions.length > 0 ? ( + plugin.permissions.map((perm) => ( + + {perm} + + )) + ) : ( + + None + + )} + +
+
+ + Scopes + + + {plugin.scopes.length > 0 ? ( + plugin.scopes.map((scope) => ( + + {scope} + + )) + ) : ( + + None + + )} + +
+
+ + Libraries + + + {plugin.libraryIds.length === 0 ? ( + + All Libraries + + ) : ( + plugin.libraryIds.map((libId) => { + const lib = libraries.find((l) => l.id === libId); + return ( + + {lib?.name || libId} + + ); + }) + )} + +
+
+ + {plugin.manifest && ( + <> + + +
+ + Version + + {plugin.manifest.version} +
+
+ + Protocol + + v{plugin.manifest.protocolVersion} +
+ {plugin.manifest.author && ( +
+ + Author + + {plugin.manifest.author} +
+ )} +
+ + {plugin.manifest.capabilities.metadataProvider && ( + + Metadata Provider + + )} + {plugin.manifest.capabilities.userSyncProvider && ( + + User Sync Provider + + )} + + + )} + + {plugin.disabledReason && ( + } + color="red" + variant="outline" + > + + Disabled Reason + + + {plugin.disabledReason} + + + )} + + +
+ ); +} + +// Plugin failure history component +function PluginFailureHistory({ pluginId }: { pluginId: string }) { + const [showAllModal, setShowAllModal] = useState(false); + const [page, setPage] = useState(1); + const pageSize = 5; // Show 5 recent failures inline + const modalPageSize = 20; + + // Query for inline display (first 5) + const { data, isLoading, error } = useQuery({ + queryKey: ["plugin-failures", pluginId, "inline"], + queryFn: () => pluginsApi.getFailures(pluginId, pageSize, 0), + }); + + // Query for modal display (paginated) + const { data: modalData, isLoading: modalLoading } = + useQuery({ + queryKey: ["plugin-failures", pluginId, "modal", page], + queryFn: () => + pluginsApi.getFailures( + pluginId, + modalPageSize, + (page - 1) * modalPageSize, + ), + enabled: showAllModal, + }); + + if (isLoading) { + return ( + + + + ); + } + + if (error || !data) { + return null; + } + + if (data.failures.length === 0) { + return null; + } + + const totalPages = Math.ceil( + (modalData?.total ?? data.total) / modalPageSize, + ); + + return ( + <> + + +
+ + Window Failures + + + = data.threshold ? "red" : undefined} + > + {data.windowFailures} / {data.threshold} + + + (in {Math.round(data.windowSeconds / 60)} min) + + +
+
+ + Total Recorded + + {data.total} +
+ {data.total > pageSize && ( + + )} +
+ + + {data.failures.map((failure) => ( + + ))} + + + {/* View All Failures Modal */} + setShowAllModal(false)} + title="Failure History" + size="lg" + > + + +
+ + Window Failures + + + {data.windowFailures} / {data.threshold} + +
+
+ + Window Duration + + + {Math.round(data.windowSeconds / 60)} minutes + +
+
+ + Total Failures + + {modalData?.total ?? data.total} +
+
+ + + + {modalLoading ? ( + + + + ) : ( + + + {modalData?.failures.map((failure) => ( + + ))} + + + )} + + {totalPages > 1 && ( + + + + Page {page} of {totalPages} + + + + )} +
+
+ + ); +} + +// Individual failure card component +function FailureCard({ + failure, + showDetails = false, +}: { + failure: PluginFailuresResponse["failures"][0]; + showDetails?: boolean; +}) { + return ( + + + + + {failure.errorCode && ( + + {failure.errorCode} + + )} + {failure.method && ( + + {failure.method} + + )} + + {failure.errorMessage} + + + + {new Date(failure.occurredAt).toLocaleString()} + + + {showDetails && failure.requestSummary && ( + + + Request Summary: + + + {failure.requestSummary} + + + )} + + + ); +} + +// Plugin form component +interface PluginFormProps { + form: ReturnType>; + onSubmit: (values: PluginFormValues) => void; + isLoading: boolean; + onCancel: () => void; + isCreate?: boolean; + libraries: { id: string; name: string }[]; +} + +function PluginForm({ + form, + onSubmit, + isLoading, + onCancel, + isCreate, + libraries, +}: PluginFormProps) { + const [activeTab, setActiveTab] = useState("general"); + const [nameManuallyEdited, setNameManuallyEdited] = useState(false); + + // Check which tabs have errors + const generalTabErrors = isCreate + ? !!(form.errors.name || form.errors.displayName) + : !!form.errors.displayName; + const executionTabErrors = !!form.errors.command; + + // Handle form submission with tab navigation on error + const handleSubmit = form.onSubmit(onSubmit, (errors) => { + // Navigate to the first tab with errors + if (isCreate && errors.name) { + setActiveTab("general"); + } else if (errors.displayName) { + setActiveTab("general"); + } else if (errors.command) { + setActiveTab("execution"); + } + }); + + return ( +
+ + + + General{generalTabErrors ? " *" : ""} + + + Execution{executionTabErrors ? " *" : ""} + + Permissions + Credentials + + + + + + {isCreate && ( + { + const value = e.currentTarget.value; + form.setFieldValue("name", value); + setNameManuallyEdited(value.length > 0); + }} + onBlur={(e) => { + form.setFieldValue( + "name", + normalizePluginName(e.currentTarget.value), + ); + }} + /> + )} + { + const displayName = e.currentTarget.value; + form.setFieldValue("displayName", displayName); + // Auto-generate name from display name until user manually edits it (create mode only) + if (isCreate && !nameManuallyEdited) { + form.setFieldValue( + "name", + normalizePluginName(displayName), + ); + } + }} + /> +