openapi: 3.1.0
info:
  title: Ink Reader Provider API
  version: 1.8.0
  description: Normalized novel providers, streamable Dotbook books, and optional server-side narration for Ink Reader clients.
servers:
  - url: http://localhost:8787/api/v1
paths:
  /health:
    get:
      operationId: health
      summary: Check service health
      responses:
        "200":
          description: Service is accepting requests.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [status]
                    properties:
                      status: { type: string, const: ok }
                      proxyPool:
                        type: object
                        description: Redacted aggregate diagnostics, present only when the provider proxy pool is enabled.
                        required: [enabled, mode, candidates, healthy, spareHealthy, assigned, healthyAssignments, refreshing]
                        properties:
                          enabled: { type: boolean }
                          mode: { type: string, enum: [probe-only, provider-routing] }
                          candidates: { type: integer, minimum: 0 }
                          discoveredCandidates: { type: integer, minimum: 0 }
                          healthy: { type: integer, minimum: 0 }
                          spareHealthy: { type: integer, minimum: 0 }
                          assigned: { type: integer, minimum: 0 }
                          healthyAssignments: { type: integer, minimum: 0 }
                          medianLatencyMs: { type: integer, minimum: 0 }
                          fastestLatencyMs: { type: integer, minimum: 0 }
                          p95LatencyMs: { type: integer, minimum: 0 }
                          lastCheckedAt: { type: string, format: date-time }
                          lastError: { type: string }
                          refreshing: { type: boolean }
                          refreshChecked: { type: integer, minimum: 0 }
                          providers:
                            type: array
                            items:
                              type: object
                              required: [providerId, assigned, healthy, failovers]
                              properties:
                                providerId: { type: string }
                                assigned: { type: boolean }
                                healthy: { type: boolean }
                                latencyMs: { type: integer, minimum: 0 }
                                failovers: { type: integer, minimum: 0 }
  /audio/voices:
    get:
      operationId: getNarrationInfo
      summary: Inspect the optional narration capability
      responses:
        "200":
          description: Narration availability and server-advertised voices.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/NarrationInfo" }
  /audio/speech:
    post:
      operationId: synthesizeNarration
      summary: Generate a WAV narration segment
      description: Optional. The server may forward chapter text to its configured speech engine and caches successful audio.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NarrationRequest" }
      responses:
        "200":
          description: Generated 24 kHz mono WAV audio.
          content:
            audio/wav:
              schema: { type: string, contentMediaType: audio/wav }
        "400": { $ref: "#/components/responses/BadRequest" }
        "502": { $ref: "#/components/responses/UpstreamFailure" }
        "503":
          description: This provider server does not have narration enabled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /providers:
    get:
      operationId: listProviders
      summary: List installed providers
      responses:
        "200":
          description: Provider metadata sorted by name.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/ProviderInfo" }
  /providers/{providerId}:
    parameters:
      - $ref: "#/components/parameters/ProviderId"
    get:
      operationId: getProvider
      summary: Get one provider
      responses:
        "200":
          description: Provider metadata.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/ProviderInfo" }
        "404": { $ref: "#/components/responses/NotFound" }
  /providers/{providerId}/filters:
    parameters:
      - $ref: "#/components/parameters/ProviderId"
    get:
      operationId: getProviderFilters
      summary: Get provider catalog and search filters
      responses:
        "200":
          description: Filters that can be sent as `filter.<key>` query parameters.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Filter" }
        "404": { $ref: "#/components/responses/NotFound" }
  /providers/{providerId}/catalogs:
    parameters:
      - $ref: "#/components/parameters/ProviderId"
    get:
      operationId: getProviderCatalogs
      summary: List the provider's independent catalogue feeds
      responses:
        "200":
          description: Named feeds that clients can let users enable independently.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/CatalogDefinition" }
        "404": { $ref: "#/components/responses/NotFound" }
  /providers/{providerId}/catalog:
    parameters:
      - $ref: "#/components/parameters/ProviderId"
      - $ref: "#/components/parameters/Page"
      - name: catalog
        in: query
        schema: { type: string }
        description: A catalog ID returned by the provider's `/catalogs` endpoint.
      - name: latest
        in: query
        schema: { type: boolean, default: false }
        description: Ask the provider for recently updated titles.
    get:
      operationId: catalog
      summary: Browse a provider catalogue
      description: Provider filters are passed as query parameters named `filter.<key>`. Values may be repeated.
      responses:
        "200": { $ref: "#/components/responses/NovelPage" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/UpstreamFailure" }
  /providers/{providerId}/search:
    parameters:
      - $ref: "#/components/parameters/ProviderId"
      - $ref: "#/components/parameters/Page"
      - name: q
        in: query
        schema: { type: string, minLength: 1, maxLength: 200 }
        description: Optional when at least one `filter.<key>` parameter is supplied.
    get:
      operationId: search
      summary: Search a provider
      description: Provider filters are passed as repeated query parameters named `filter.<key>`.
      responses:
        "200": { $ref: "#/components/responses/NovelPage" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/UpstreamFailure" }
  /providers/{providerId}/author:
    parameters:
      - $ref: "#/components/parameters/ProviderId"
      - $ref: "#/components/parameters/Page"
      - name: name
        in: query
        required: true
        schema: { type: string, minLength: 1, maxLength: 200 }
      - name: path
        in: query
        description: Optional exact author path returned by a novel response from this provider.
        schema: { type: string, maxLength: 2048 }
    get:
      operationId: author
      summary: List titles by an author
      responses:
        "200": { $ref: "#/components/responses/NovelPage" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/UpstreamFailure" }
  /providers/{providerId}/novel:
    parameters:
      - $ref: "#/components/parameters/ProviderId"
      - $ref: "#/components/parameters/SourcePath"
    get:
      operationId: novel
      summary: Fetch novel metadata and chapters
      responses:
        "200":
          description: Normalized novel details.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Novel" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/UpstreamFailure" }
  /providers/{providerId}/chapter:
    parameters:
      - $ref: "#/components/parameters/ProviderId"
      - $ref: "#/components/parameters/SourcePath"
    get:
      operationId: chapter
      summary: Fetch sanitized chapter HTML
      description: >
        The optional `meta.cache` value is `hit`, `miss`, `stale`, or `unknown`.
        Download clients may use it to pace uncached upstream requests.
      responses:
        "200":
          description: Reader-safe chapter content.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Chapter" }
                      meta:
                        type: object
                        properties:
                          provider: { type: string }
                          cache:
                            type: string
                            enum: [hit, miss, stale, unknown]
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/UpstreamFailure" }
  /providers/{providerId}/dotbook:
    parameters:
      - $ref: "#/components/parameters/ProviderId"
      - $ref: "#/components/parameters/SourcePath"
    get:
      operationId: dotbookManifest
      summary: Fetch a streamable Dotbook manifest
      description: >
        Projects provider metadata and chapter order into the same manifest
        model used by packaged .book archives.
      responses:
        "200":
          description: A stream-delivered Dotbook manifest.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/DotbookManifest" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/UpstreamFailure" }
  /providers/{providerId}/dotbook/chapter:
    parameters:
      - $ref: "#/components/parameters/ProviderId"
      - $ref: "#/components/parameters/SourcePath"
    get:
      operationId: dotbookChapter
      summary: Fetch a chapter as renderer-independent Mark JSON
      description: >
        Converts the provider's sanitized source chapter into the JSON AST
        emitted by the Mark toolchain. The optional `meta.cache` value is
        `hit`, `miss`, `stale`, or `unknown`.
      responses:
        "200":
          description: A Mark JSON document.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/MarkDocument" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/UpstreamFailure" }
  /providers/{providerId}/images/{imageKey}:
    parameters:
      - $ref: "#/components/parameters/ProviderId"
      - name: imageKey
        in: path
        required: true
        description: Opaque SHA-256 image key returned in `coverCachePath`.
        schema:
          type: string
          pattern: "^[a-f0-9]{64}$"
    get:
      operationId: getCachedProviderImage
      summary: Load a provider image through the bounded server cache
      responses:
        "200":
          description: Cached or freshly retrieved image bytes.
          headers:
            X-Ink-Cache:
              schema: { type: string, enum: [hit, miss, stale] }
          content:
            image/*:
              schema: { type: string, format: binary }
        "304": { description: The client already has the current image. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "502": { $ref: "#/components/responses/UpstreamFailure" }
components:
  parameters:
    ProviderId:
      name: providerId
      in: path
      required: true
      schema: { type: string, example: novelfire }
    Page:
      name: page
      in: query
      schema: { type: integer, minimum: 1, maximum: 10000, default: 1 }
    SourcePath:
      name: path
      in: query
      required: true
      description: Opaque path returned by this provider in an earlier response.
      schema: { type: string, minLength: 1, maxLength: 2048 }
  schemas:
    Envelope:
      type: object
      required: [data]
      properties:
        data: {}
        meta:
          type: object
          additionalProperties: true
    ProviderInfo:
      type: object
      required: [id, name, language, site, version, capabilities]
      properties:
        id: { type: string }
        name: { type: string }
        language: { type: string }
        site: { type: string, format: uri }
        version: { type: string }
        iconUrl: { type: string, format: uri }
        iconCachePath:
          type: string
          description: Relative API-root path for the same icon through the provider server's bounded image cache.
        coverAspectRatio:
          type: number
          description: Native width divided by height for covers supplied by this provider.
        capabilities:
          type: array
          items: { type: string }
    NarrationVoice:
      type: object
      required: [id, name]
      properties:
        id: { type: string }
        name: { type: string }
    NarrationInfo:
      type: object
      required: [enabled, maxInputCharacters, voices]
      properties:
        enabled: { type: boolean }
        model: { type: string }
        defaultVoice: { type: string }
        maxInputCharacters: { type: integer, minimum: 1 }
        voices:
          type: array
          items: { $ref: "#/components/schemas/NarrationVoice" }
    NarrationRequest:
      type: object
      required: [text]
      additionalProperties: false
      properties:
        text: { type: string, minLength: 1, maxLength: 1200 }
        voice: { type: string }
        speed: { type: number, minimum: 0.5, maximum: 2, default: 1 }
    CatalogDefinition:
      type: object
      required: [id, name, defaultEnabled]
      properties:
        id: { type: string }
        name: { type: string }
        description: { type: string }
        defaultEnabled: { type: boolean }
    FilterOption:
      type: object
      required: [label, value]
      properties:
        label: { type: string }
        value: { type: string }
    Filter:
      type: object
      required: [key, label, type]
      properties:
        key: { type: string }
        label: { type: string }
        type: { type: string, enum: [select, multi-select] }
        default: {}
        multiple: { type: boolean }
        options:
          type: array
          items: { $ref: "#/components/schemas/FilterOption" }
    NovelSummary:
      type: object
      required: [path, name]
      properties:
        path:
          type: string
          description: Mark resource reference retaining its leading @; streamed resources normally point to an API-root-relative image-cache path.
        name: { type: string }
        author: { type: string }
        cover: { type: string, format: uri }
        coverCachePath:
          type: string
          description: Relative API-root path for the same cover through the provider server's bounded image cache.
        providerId: { type: string }
        providerName: { type: string }
        coverAspectRatio:
          type: number
          description: Native width divided by height for this source's covers.
    ChapterSummary:
      type: object
      required: [path, name]
      properties:
        path: { type: string }
        name: { type: string }
        number: { type: number }
        releasedAt: { type: string }
        scanlator: { type: string }
        externalUrl: { type: string, format: uri }
        sourcePageKey: { type: string }
    Novel:
      allOf:
        - $ref: "#/components/schemas/NovelSummary"
        - type: object
          required: [chapters]
          properties:
            authorPath: { type: string, description: Opaque source-owned author path for exact lookups. }
            authorUrl: { type: string, format: uri }
            supportLinks:
              type: array
              items: { $ref: "#/components/schemas/SupportLink" }
            artist: { type: string }
            summary: { type: string }
            status: { type: string, enum: [ongoing, completed, on_hiatus, stub, cancelled, unknown] }
            genres:
              type: array
              items: { type: string }
            rating: { type: number }
            chapters:
              type: array
              items: { $ref: "#/components/schemas/ChapterSummary" }
            totalPages: { type: integer }
            wordCount:
              type: integer
              format: int64
              description: Exact total when every chapter body has been cached.
            estimatedWordCount:
              type: integer
              format: int64
              description: Estimated total based on the mean size of cached chapters.
            cachedChapterCount:
              type: integer
              description: Number of cached chapter bodies used for word statistics.
            wordCountComplete:
              type: boolean
              description: True when wordCount covers every chapter in the novel.
    SupportLink:
      type: object
      required: [label, url]
      properties:
        label: { type: string }
        url: { type: string, format: uri }
        type: { type: string, examples: [patreon, paypal, kofi, other] }
    Chapter:
      type: object
      required: [path, content, contentType]
      properties:
        path: { type: string }
        name: { type: string }
        number: { type: number }
        content: { type: string, description: Sanitized HTML fragment. }
        contentType: { type: string, const: "text/html; charset=utf-8" }
        wordCount: { type: integer, description: Unicode-aware word count for this chapter. }
    DotbookResource:
      type: object
      required: [href]
      properties:
        href:
          type: string
          description: Absolute source URL or API-root-relative cached resource path.
        mediaType: { type: string }
        aspectRatio: { type: number, exclusiveMinimum: 0 }
    DotbookChapterReference:
      type: object
      required: [id, href, title, contentType]
      properties:
        id: { type: string }
        href:
          type: string
          description: Opaque source-owned chapter path supplied to the Dotbook chapter endpoint.
        title: { type: string }
        number: { type: number }
        releasedAt: { type: string }
        role: { type: string, enum: [chapter, frontmatter, postmatter] }
        contentType: { type: string, const: "application/vnd.mark+json" }
        metadata:
          type: object
          additionalProperties: true
    DotbookManifest:
      type: object
      required: [dotbook, id, title, readingOrder, delivery]
      properties:
        dotbook: { type: integer, const: 1 }
        id: { type: string }
        title: { type: string }
        author: { type: string }
        description: { type: string }
        language: { type: string }
        genres:
          type: array
          items: { type: string }
        metadata:
          type: object
          additionalProperties: true
        cover: { $ref: "#/components/schemas/DotbookResource" }
        readingOrder:
          type: array
          items: { $ref: "#/components/schemas/DotbookChapterReference" }
        delivery:
          type: object
          required: [mode]
          properties:
            mode: { type: string, const: stream }
            chapterEndpoint:
              type: string
              description: API-root-relative endpoint for reading-order href values.
    MarkMetadataEntry:
      type: object
      required: [key, value]
      properties:
        line: { type: integer, minimum: 1 }
        key: { type: string }
        value: { type: string }
    MarkInline:
      type: object
      required: [type]
      properties:
        type:
          type: string
          enum: [text, italic, bold, underline, strike, code, link, local_path, note]
        text: { type: string }
        target: { type: string }
        weight: { type: integer, minimum: 1, maximum: 4 }
        children:
          type: array
          items: { $ref: "#/components/schemas/MarkInline" }
    MarkBlock:
      type: object
      required: [type]
      properties:
        type:
          type: string
          enum: [heading, paragraph, scene_break, quote, list, resource, fenced, table, unknown]
        line: { type: integer, minimum: 1 }
        level: { type: integer, minimum: 1, maximum: 6 }
        text: { type: string }
        label: { type: string }
        ordered: { type: boolean }
        items:
          type: array
          items: { type: object, additionalProperties: true }
        lines:
          type: array
          items: { type: string }
        path: { type: string }
        placement: { type: string, enum: [left, right, full, page] }
        caption: { type: string }
        fence: { type: string }
        info: { type: string }
        body:
          type: array
          items: { type: string }
        table: { type: object, additionalProperties: true }
        inlines:
          type: array
          items: { $ref: "#/components/schemas/MarkInline" }
        quote_lines:
          type: array
          items:
            type: array
            items: { $ref: "#/components/schemas/MarkInline" }
        caption_inlines:
          type: array
          items: { $ref: "#/components/schemas/MarkInline" }
    MarkDocument:
      type: object
      required: [blocks]
      properties:
        metadata:
          type: array
          items: { $ref: "#/components/schemas/MarkMetadataEntry" }
        blocks:
          type: array
          items: { $ref: "#/components/schemas/MarkBlock" }
    NovelPage:
      type: object
      required: [items, page]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/NovelSummary" }
        page: { type: integer }
        hasMore: { type: boolean }
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string }
            message: { type: string }
            requestId: { type: string }
  responses:
    NovelPage:
      description: A normalized page of titles.
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/Envelope"
              - type: object
                properties:
                  data: { $ref: "#/components/schemas/NovelPage" }
    BadRequest:
      description: Invalid query or source path.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Provider or content not found.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: The upstream source rejected or rate-limited the request.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    UpstreamFailure:
      description: The upstream provider is unavailable or its response changed.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
