openapi: 3.1.0
info:
  title: Lagoon API
  version: '1'
  description: |-
    Art metadata API. Search tagged artworks across platforms using structured
    parameters or plain English. Returns JSON with tags, artist handles,
    cross-platform links, post metadata, and scaled thumbnails.

    CORS is enabled for all origins. Every response includes an `ok` boolean.

    SFW filtering is on by default. All queries return only `general` and
    `sensitive` content unless `sfw=0` is passed or a specific `rating` is set.
  contact:
    name: Lagoon
    url: https://lagoon.io
    email: contact@lagoon.io

servers:
  - url: https://dev.lagoon.io/api/v1

security: []

tags:
  - name: Search
    description: Full-text, tag, and natural language search.
  - name: Random
    description: Random post sampling with optional filters.
  - name: Artist
    description: Cross-platform artist lookup.
  - name: Post
    description: Single post metadata.
  - name: Tags
    description: Tag index search.
  - name: Similar
    description: Semantic similarity search via vector embeddings.
  - name: SimilarArtists
    description: Find artists with similar visual style (analytics tier).
  - name: BatchSimilar
    description: Pairwise cosine similarity matrix (compute tier, per-post billing).
  - name: Thumb
    description: Scaled post thumbnails (image/webp).
  - name: CoOccurrence
    description: Tag co-occurrence data (analytics tier).
  - name: Trending
    description: Trending tags (analytics tier).
  - name: Stats
    description: Index-wide statistics.

paths:
  /search:
    get:
      operationId: searchPosts
      summary: Search posts
      description: |-
        Find posts by natural language, full-text search, tag, artist, or
        platform. At least one of `nl`, `q`, `tag`, `artist`, `since`, or `color` is required.
        Results are ordered by relevance when `q` is used, and by date
        otherwise.
      tags: [Search]
      security:
        - {}
        - ApiKeyAuth: []
      parameters:
        - name: nl
          in: query
          schema:
            type: string
          description: |-
            Natural language query. The API extracts platform, artist, date
            range, tags, rating, sort order, and exclusions from the text.
            Leftover words become a full-text search. When combined with
            explicit parameters, the explicit parameter wins for the same
            field.
        - name: q
          in: query
          schema:
            type: string
          description: |-
            Full-text search against post titles, descriptions, and indexed
            content. Supports quoted phrases, `OR`, and `-exclusion`.
        - name: tag
          in: query
          schema:
            type: string
          description: |-
            Exact tag match in normalized form (e.g. `genshin_impact`). Use
            `/tags` to discover available tags.
        - name: artist
          in: query
          schema:
            type: string
          description: Artist handle or display name (case-insensitive pattern match).
        - name: platform
          in: query
          schema:
            type: string
            enum: [x, pixiv, bluesky, deviantart, artstation, newgrounds, tumblr, zerochan]
          description: Platform slug.
        - name: sfw
          in: query
          schema:
            type: string
          description: |-
            SFW filter. On by default (returns only `general` and `sensitive`
            rated posts). Set to `0` to include all ratings.
        - name: rating
          in: query
          schema:
            type: string
            enum: [general, sensitive, questionable, explicit]
          description: Filter to a specific rating. Overrides `sfw` if both are set.
        - name: from
          in: query
          schema:
            type: string
            format: date
          description: Start date filter (YYYY-MM-DD).
        - name: to
          in: query
          schema:
            type: string
            format: date
          description: End date filter (YYYY-MM-DD).
        - name: since
          in: query
          schema:
            type: string
            format: date-time
          description: |-
            Ingestion timestamp filter (ISO 8601). Only return posts added to
            the index on or after this time. Unlike `from`/`to` (which filter
            by publication date), `since` filters by when the post was indexed.
        - name: sort
          in: query
          schema:
            type: string
            enum: [newest, oldest]
            default: newest
          description: Sort order by date. Default is newest first.
        - name: color
          in: query
          schema:
            type: string
          description: |-
            Filter by dominant color in the post's extracted palette. Accepts
            named colors (red, orange, yellow, green, cyan, teal, blue,
            purple, pink, brown, black, white, gray), moods (pastel, dark,
            warm, cool), or hex codes (#FF6600). Comma-separate for AND
            logic (e.g. `blue,warm`). Maximum 4 colors per query. ~79% of
            posts have palette data.
          examples:
            named:
              value: blue
            multi:
              value: blue,warm
            hex:
              value: '#FF6600'
        - name: facets
          in: query
          schema:
            type: string
            enum: ['1']
          description: |-
            Set to `1` to include faceted counts in the response. Returns
            total result count, per-platform breakdown, and per-rating
            breakdown for the full unpaginated result set.
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
          description: Results per page.
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
          description: Pagination offset.
      responses:
        '200':
          description: Search results.
          content:
            application/json:
              schema:
                type: object
                required: [ok, count, offset, results]
                properties:
                  ok:
                    type: boolean
                    const: true
                  count:
                    type: integer
                    description: Number of results in this page.
                  offset:
                    type: integer
                  filters:
                    type: object
                    description: |-
                      Present only when the `nl` parameter is used. Shows what
                      the parser extracted from the natural language input.
                    additionalProperties: true
                  facets:
                    type: object
                    description: |-
                      Present only when `facets=1` is passed. Contains
                      aggregate counts for the full unpaginated result set.
                    properties:
                      total:
                        type: integer
                        description: Total matching posts (unpaginated).
                      platforms:
                        type: array
                        items:
                          type: object
                          required: [slug, name, count]
                          properties:
                            slug:
                              type: string
                            name:
                              type: string
                            count:
                              type: integer
                      ratings:
                        type: array
                        items:
                          type: object
                          required: [rating, count]
                          properties:
                            rating:
                              type: string
                              enum: [general, sensitive, questionable, explicit]
                            count:
                              type: integer
                  results:
                    type: array
                    items:
                      $ref: '#/components/schemas/SearchResult'
              example:
                ok: true
                count: 1
                offset: 0
                results:
                  - id: 184201
                    source_url: https://x.com/artist/status/123456
                    thumb_url: https://dev.lagoon.io/api/v1/thumb?id=184201
                    title: character name (series) by artist
                    description: null
                    posted_at: '2025-11-15 08:30:00+00'
                    platform: x
                    platform_name: X (Twitter)
                    artist_handle: artist_name
                    artist_name: Artist Name
                    deleted: false
                    description_long: null
                    tags:
                      - 1girl
                      - blue hair
                      - 'character:name'
                      - 'copyright:series'
                      - 'rating:general'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientBalance'
        '429':
          $ref: '#/components/responses/RateLimited'

  /random:
    get:
      operationId: randomPosts
      summary: Get random posts
      description: |-
        Return random posts from the index. Accepts the same filters as
        `/search` (tag, platform, artist, date range, rating, NL) to narrow
        the pool before sampling. No required parameters.
      tags: [Random]
      security:
        - {}
        - ApiKeyAuth: []
      parameters:
        - name: nl
          in: query
          schema: { type: string }
          description: Natural language filter. Same parser as `/search`.
        - name: q
          in: query
          schema: { type: string }
          description: Full-text search filter.
        - name: tag
          in: query
          schema: { type: string }
          description: Exact tag match (normalized form).
        - name: artist
          in: query
          schema: { type: string }
          description: Artist handle or display name (case-insensitive).
        - name: platform
          in: query
          schema:
            type: string
            enum: [x, pixiv, bluesky, deviantart, artstation, newgrounds, tumblr, zerochan]
          description: Platform slug.
        - name: sfw
          in: query
          schema: { type: string, default: '1' }
          description: SFW filter, on by default. Set to `0` for all ratings.
        - name: rating
          in: query
          schema:
            type: string
            enum: [general, sensitive, questionable, explicit]
          description: Specific rating filter.
        - name: from
          in: query
          schema: { type: string, format: date }
          description: Start date (YYYY-MM-DD).
        - name: to
          in: query
          schema: { type: string, format: date }
          description: End date (YYYY-MM-DD).
        - name: color
          in: query
          schema: { type: string }
          description: |-
            Filter by dominant color. Same values as `/search`: named
            colors, moods, or hex codes. Comma-separate for AND logic.
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 1 }
          description: Number of random posts to return.
      responses:
        '200':
          description: Random post(s) returned.
          content:
            application/json:
              schema:
                type: object
                required: [ok, count, results]
                properties:
                  ok: { type: boolean, const: true }
                  count: { type: integer }
                  filters:
                    type: object
                    description: Present when `nl` is used. Shows extracted filters.
                  results:
                    type: array
                    items:
                      $ref: '#/components/schemas/SearchResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientBalance'
        '429':
          $ref: '#/components/responses/RateLimited'

  /artist:
    get:
      operationId: lookupArtist
      summary: Look up an artist
      description: |-
        Look up an artist by handle and get all linked accounts across
        platforms.
      tags: [Artist]
      security:
        - {}
        - ApiKeyAuth: []
      parameters:
        - name: handle
          in: query
          required: true
          schema:
            type: string
          description: |-
            Artist handle to look up. When `platform` is set, must be the
            platform-specific handle (e.g. a Pixiv numeric user ID).
        - name: platform
          in: query
          schema:
            type: string
            enum: [x, pixiv, bluesky, deviantart, artstation, newgrounds, tumblr, zerochan]
          description: Narrow the handle match to a specific platform.
      responses:
        '200':
          description: Artist found.
          content:
            application/json:
              schema:
                type: object
                required: [ok, artist]
                properties:
                  ok:
                    type: boolean
                    const: true
                  artist:
                    $ref: '#/components/schemas/Artist'
              example:
                ok: true
                artist:
                  handle: hews__
                  display_name: hews
                  post_count: 153
                  platforms:
                    - platform: x
                      platform_name: X (Twitter)
                      handle: hews__
                      display_name: hews
                      profile_url: https://x.com/hews__
                    - platform: pixiv
                      platform_name: Pixiv
                      handle: '4338012'
                      display_name: hews
                      profile_url: https://www.pixiv.net/users/4338012
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /post:
    get:
      operationId: getPost
      summary: Get a single post
      description: |-
        Get full metadata for a single post by ID. Returns `artist_profile`
        in addition to the fields present in search results. Posts where the
        original was removed by the artist have `deleted: true` and a null
        `source_url`. Tags and metadata remain available. The `/search`
        endpoint excludes deleted posts; `/post` returns them by ID.
      tags: [Post]
      security:
        - {}
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: query
          required: true
          schema:
            type: integer
            minimum: 1
          description: Post ID.
      responses:
        '200':
          description: Post found.
          content:
            application/json:
              schema:
                type: object
                required: [ok, post]
                properties:
                  ok:
                    type: boolean
                    const: true
                  post:
                    $ref: '#/components/schemas/PostDetail'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /tags:
    get:
      operationId: searchTags
      summary: Search the tag index
      description: |-
        Search the tag index. Use this to discover available tags, build
        autocomplete, or check post counts. Results are sorted by category
        priority (character, copyright, then general) and then by post count
        within each category. Only tags with at least 3 posts are returned.
      tags: [Tags]
      security:
        - {}
        - ApiKeyAuth: []
      parameters:
        - name: q
          in: query
          required: true
          schema:
            type: string
            minLength: 2
          description: Tag name search (min 2 characters). Matches anywhere in the tag name.
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 20
          description: Max results.
      responses:
        '200':
          description: Tag results.
          content:
            application/json:
              schema:
                type: object
                required: [ok, count, tags]
                properties:
                  ok:
                    type: boolean
                    const: true
                  count:
                    type: integer
                  tags:
                    type: array
                    items:
                      $ref: '#/components/schemas/Tag'
              example:
                ok: true
                count: 3
                tags:
                  - name: 'character:ganyu (genshin impact)'
                    normalized: character_ganyu_genshin_impact
                    post_count: 2116
                    category: character
                  - name: 'character:mona (genshin impact)'
                    normalized: character_mona_genshin_impact
                    post_count: 1058
                    category: character
                  - name: 'character:lumine (genshin impact)'
                    normalized: character_lumine_genshin_impact
                    post_count: 982
                    category: character
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  /similar:
    get:
      operationId: findSimilar
      summary: Find similar posts
      description: |-
        Find semantically similar posts using vector embeddings. Two modes:
        pass a post ID to find posts like it, or pass a text description for
        semantic search. At least one of `id` or `q` is required. Results are
        ranked by cosine similarity (0 to 1, where 1 is identical).
      tags: [Similar]
      security:
        - {}
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: query
          schema:
            type: integer
            minimum: 1
          description: Post ID to find similar posts for. Mutually exclusive with `q`.
        - name: q
          in: query
          schema:
            type: string
            minLength: 3
            maxLength: 500
          description: |-
            Text description to match semantically (3-500 characters).
            Mutually exclusive with `id`.
        - name: platform
          in: query
          schema:
            type: string
            enum: [x, pixiv, bluesky, deviantart, artstation, newgrounds, tumblr, zerochan]
          description: Filter results to a specific platform slug.
        - name: artist
          in: query
          schema:
            type: string
          description: Filter results to a specific artist handle (case-insensitive).
        - name: sfw
          in: query
          schema:
            type: string
          description: SFW filter, on by default. Set to `0` to include all ratings.
        - name: rating
          in: query
          schema:
            type: string
            enum: [general, sensitive, questionable, explicit]
          description: Filter to a specific rating. Overrides `sfw`.
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 10
          description: Max results.
      responses:
        '200':
          description: Similar posts found.
          content:
            application/json:
              schema:
                type: object
                required: [ok, count, results]
                properties:
                  ok:
                    type: boolean
                    const: true
                  count:
                    type: integer
                  results:
                    type: array
                    items:
                      $ref: '#/components/schemas/SimilarResult'
              example:
                ok: true
                count: 1
                results:
                  - id: 216477
                    source_url: https://www.pixiv.net/artworks/100389499
                    thumb_url: https://dev.lagoon.io/api/v1/thumb?id=216477
                    title: character (series) by artist
                    description: null
                    posted_at: '2022-10-24 17:23:58+00'
                    platform: pixiv
                    platform_name: Pixiv
                    artist_handle: '76910634'
                    artist_name: artist name
                    deleted: false
                    tags:
                      - 1girl
                      - blue hair
                      - sword
                    similarity: 0.8734
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: Embedding service unavailable (text query mode only).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /similar-artists:
    get:
      operationId: getSimilarArtists
      summary: Find artists with similar style
      description: |-
        Find artists with a similar visual style. Each artist in the index has
        a pre-computed style vector derived from their body of work. Pass an
        artist handle to find stylistically similar artists, or a post ID to
        find artists whose overall style matches that post.

        Requires an API key. Billed at the analytics rate (10 mills per call).
      tags: [SimilarArtists]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: handle
          in: query
          schema:
            type: string
          description: Artist handle to find similar artists for. Mutually exclusive with `id`.
        - name: platform
          in: query
          schema:
            type: string
          description: Platform slug to disambiguate the handle (e.g. `x`, `pixiv`). Optional.
        - name: id
          in: query
          schema:
            type: integer
          description: Post ID. Finds artists whose style matches this post. Mutually exclusive with `handle`.
        - name: limit
          in: query
          schema:
            type: integer
            default: 10
            maximum: 50
          description: Max results to return.
      responses:
        '200':
          description: Similar artists found.
          content:
            application/json:
              schema:
                type: object
                required: [ok, count, results]
                properties:
                  ok:
                    type: boolean
                    const: true
                  count:
                    type: integer
                  results:
                    type: array
                    items:
                      $ref: '#/components/schemas/SimilarArtistResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Analytics endpoints require an API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /batch-similar:
    get:
      operationId: getBatchSimilar
      summary: Pairwise similarity matrix
      description: |-
        Compute cosine similarity for all pairs in a set of post IDs. Returns
        pairs above a threshold, sorted by similarity descending.

        Requires an API key. Billed per input post ID: 10 mills each.
        Usage tier: max 100 IDs. Pro tier: max 200 IDs.
      tags: [BatchSimilar]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: ids
          in: query
          required: true
          schema:
            type: string
          description: Comma-separated post IDs. Minimum 2.
          example: '5995,6001,6050'
        - name: threshold
          in: query
          schema:
            type: number
            default: 0.5
            minimum: 0
            maximum: 1
          description: Minimum similarity to include a pair.
      responses:
        '200':
          description: Pairwise similarities computed.
          content:
            application/json:
              schema:
                type: object
                required: [ok, count, pairs]
                properties:
                  ok:
                    type: boolean
                    const: true
                  count:
                    type: integer
                  pairs:
                    type: array
                    items:
                      type: object
                      required: [id_a, id_b, similarity]
                      properties:
                        id_a:
                          type: integer
                        id_b:
                          type: integer
                          description: Always greater than id_a.
                        similarity:
                          type: number
                          format: float
                          minimum: 0
                          maximum: 1
                  missing_ids:
                    type: array
                    items:
                      type: integer
                    description: Post IDs from request that had no embedding. Omitted if all found.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientBalance'
        '403':
          description: Requires an API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'

  /co-occurrence:
    get:
      operationId: getCoOccurrence
      summary: Tag co-occurrence
      description: |-
        Returns tags that frequently appear alongside a given tag, with counts
        and Jaccard similarity scores. Requires an API key. Billed at the
        analytics rate (10 mills per call).
      tags: [CoOccurrence]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: tag
          in: query
          required: true
          schema:
            type: string
          description: Tag name to look up co-occurrences for. Matched by normalized form or by highest post count.
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            maximum: 100
          description: Max results to return.
        - name: min_count
          in: query
          schema:
            type: integer
            default: 10
          description: Minimum co-occurrence count to include.
      responses:
        '200':
          description: Co-occurrence data found.
          content:
            application/json:
              schema:
                type: object
                required: [ok, tag, tag_post_count, count, co_occurrences]
                properties:
                  ok:
                    type: boolean
                    const: true
                  tag:
                    type: string
                    description: The resolved tag name.
                    example: 'copyright:blue archive'
                  tag_post_count:
                    type: integer
                    description: Number of active posts with this tag.
                    example: 12844
                  count:
                    type: integer
                  co_occurrences:
                    type: array
                    items:
                      $ref: '#/components/schemas/CoOccurrenceResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientBalance'
        '403':
          description: Requires an API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'

  /trending:
    get:
      operationId: getTrendingTags
      summary: Trending tags
      description: |-
        Tags appearing disproportionately often in recently indexed posts
        compared to their historical rate. The trend score is the ratio of
        observed frequency to expected frequency.

        Requires an API key. Billed at the analytics rate (10 mills per call).
      tags: [Trending]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: window
          in: query
          schema:
            type: string
            enum: ['1h', '6h', '12h', '24h', '48h', '7d']
            default: '24h'
          description: Time window to measure.
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            minimum: 1
            maximum: 50
          description: Max results to return.
        - name: min_posts
          in: query
          schema:
            type: integer
            default: 10
            minimum: 1
          description: Minimum total post count for a tag to be eligible.
        - name: category
          in: query
          schema:
            type: string
            enum: [character, copyright, species, rating, general]
          description: Filter to a specific tag category.
      responses:
        '200':
          description: Trending tags.
          content:
            application/json:
              schema:
                type: object
                required: [ok, window, count, tags]
                properties:
                  ok:
                    type: boolean
                    const: true
                  window:
                    type: string
                    description: The time window used.
                    example: '24h'
                  count:
                    type: integer
                  tags:
                    type: array
                    items:
                      $ref: '#/components/schemas/TrendingTag'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientBalance'
        '403':
          description: Requires an API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'

  /thumb:
    get:
      operationId: getThumb
      summary: Get post thumbnail
      description: |-
        Returns a scaled thumbnail image (WebP, max 360px on the longest side)
        for the given post ID. Unlike other endpoints, the response body is a
        binary image, not JSON. Errors are still returned as JSON.

        No API key is required. Thumbnail requests do not count toward API rate
        limits or usage billing; they have a separate IP-based limit of 300
        requests per minute. Responses include `Cache-Control` and
        `Last-Modified` headers; conditional requests via `If-Modified-Since`
        return 304 when the thumbnail has not changed.
      tags: [Thumb]
      security:
        - {}
      parameters:
        - name: id
          in: query
          required: true
          schema:
            type: integer
            minimum: 1
          description: Post ID.
      responses:
        '200':
          description: Thumbnail image.
          content:
            image/webp:
              schema:
                type: string
                format: binary
          headers:
            Cache-Control:
              schema:
                type: string
                example: 'public, max-age=86400'
            Last-Modified:
              schema:
                type: string
                example: 'Fri, 23 May 2025 12:44:00 GMT'
        '304':
          description: Not modified (conditional request matched).
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /stats:
    get:
      operationId: getStats
      summary: Get index statistics
      description: |-
        Returns index-wide statistics: total posts, artists, tags, and
        per-platform post counts. No parameters required.
      tags: [Stats]
      security:
        - {}
        - ApiKeyAuth: []
      parameters: []
      responses:
        '200':
          description: Index statistics.
          content:
            application/json:
              schema:
                type: object
                required: [ok, total_posts, total_artists, total_tags, platforms]
                properties:
                  ok:
                    type: boolean
                    const: true
                  total_posts:
                    type: integer
                  total_artists:
                    type: integer
                  total_tags:
                    type: integer
                    description: Tags with at least 3 posts.
                  platforms:
                    type: array
                    description: Per-platform breakdown sorted by post count descending.
                    items:
                      $ref: '#/components/schemas/PlatformStats'
        '429':
          $ref: '#/components/responses/RateLimited'

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: |-
        API key for authenticated access. No key is needed for the free tier
        (30 requests per minute). Usage tier keys allow 120 rpm at $2 per
        1,000 requests. Pro tier keys allow 300 rpm at $25/month flat.

  schemas:
    PostBase:
      type: object
      description: Base post fields shared across endpoints.
      required: [id, deleted]
      properties:
        id:
          type: integer
          example: 184201
        source_url:
          type:
            - string
            - 'null'
          description: URL of the original post. Null when the post has been deleted by the artist.
        thumb_url:
          type: string
          format: uri
          description: URL to the scaled thumbnail image (WebP, max 360px). Does not require authentication.
          example: https://dev.lagoon.io/api/v1/thumb?id=184201
        title:
          type:
            - string
            - 'null'
        description:
          type:
            - string
            - 'null'
          description: Short description from the source platform.
        posted_at:
          type:
            - string
            - 'null'
          format: date-time
          example: '2025-11-15 08:30:00+00'
        platform:
          type: string
          description: Platform slug.
          example: x
        platform_name:
          type: string
          description: Human-readable platform name.
          example: X (Twitter)
        artist_handle:
          type: string
          example: artist_name
        artist_name:
          type: string
          example: Artist Name
        deleted:
          type: boolean
          description: True when the original post was removed by the artist.
        width:
          type:
            - integer
            - 'null'
          description: Original image width in pixels. Null when not available from source platform (~99.8% coverage).
          example: 1920
        height:
          type:
            - integer
            - 'null'
          description: Original image height in pixels. Null when not available from source platform (~99.8% coverage).
          example: 2560
        palette:
          type:
            - array
            - 'null'
          items:
            type: string
            pattern: '^#[0-9A-Fa-f]{6}$'
          description: Up to 6 dominant hex colors extracted from the image, ordered by dominance. Null when not available (~79% coverage).
          example: ['#656AA7', '#8888B6', '#C7C7F9', '#87F9FC', '#FBE7FC', '#05E7F9']

    SearchResult:
      description: Post returned by the search endpoint.
      allOf:
        - $ref: '#/components/schemas/PostBase'
        - type: object
          properties:
            description_long:
              type:
                - string
                - 'null'
              description: Extended description of the artwork.
            tags:
              type: array
              items:
                type: string
              description: |-
                Tag names including category-prefixed tags
                (e.g. `character:name`, `copyright:series`, `rating:general`).

    SimilarResult:
      description: Post returned by the similar endpoint, with a similarity score.
      allOf:
        - $ref: '#/components/schemas/PostBase'
        - type: object
          required: [similarity]
          properties:
            tags:
              type: array
              items:
                type: string
            similarity:
              type: number
              format: float
              minimum: 0
              maximum: 1
              description: Cosine similarity score. Values above 0.8 indicate strong similarity.
              example: 0.8734

    PostDetail:
      description: Full post metadata returned by the post endpoint.
      allOf:
        - $ref: '#/components/schemas/PostBase'
        - type: object
          properties:
            description_long:
              type:
                - string
                - 'null'
              description: Extended description of the artwork.
            artist_profile:
              type:
                - string
                - 'null'
              description: URL to the artist's profile on the source platform.
            tags:
              type: array
              items:
                type: string

    Tag:
      type: object
      required: [name, normalized, post_count, category]
      properties:
        name:
          type: string
          description: Display name of the tag.
          example: 'character:ganyu (genshin impact)'
        normalized:
          type: string
          description: Normalized form to pass to `/search?tag=`.
          example: character_ganyu_genshin_impact
        post_count:
          type: integer
          example: 2116
        category:
          type: string
          enum: [character, copyright, species, rating, general]

    Artist:
      type: object
      required: [handle, display_name, post_count, platforms]
      properties:
        handle:
          type: string
          example: hews__
        display_name:
          type: string
          example: hews
        post_count:
          type: integer
          description: Number of indexed posts by this artist.
          example: 153
        platforms:
          type: array
          description: All linked accounts across platforms.
          items:
            $ref: '#/components/schemas/ArtistPlatform'

    ArtistPlatform:
      type: object
      required: [platform, handle]
      properties:
        platform:
          type: string
          description: Platform slug.
          example: x
        platform_name:
          type: string
          description: Human-readable platform name.
          example: X (Twitter)
        handle:
          type: string
          example: hews__
        display_name:
          type: string
          example: hews
        profile_url:
          type:
            - string
            - 'null'
          description: URL to the artist's profile on this platform.
          example: https://x.com/hews__

    SimilarArtistResult:
      type: object
      required: [handle, platform, post_count, similarity]
      properties:
        handle:
          type: string
          description: Primary handle for this artist.
          example: dondayo_
        platform:
          type: string
          description: Platform slug for the primary handle.
          example: x
        post_count:
          type: integer
          description: Number of indexed posts for this handle.
          example: 102
        similarity:
          type: number
          format: float
          minimum: 0
          maximum: 1
          description: Cosine similarity of style vectors (0 to 1).
          example: 0.9885
        also_on:
          type: array
          description: Other known handles for the same artist. Omitted if none linked.
          items:
            type: object
            required: [handle, platform]
            properties:
              handle:
                type: string
              platform:
                type: string

    CoOccurrenceResult:
      type: object
      required: [tag, category, count, jaccard]
      properties:
        tag:
          type: string
          description: Co-occurring tag name.
          example: halo
        category:
          type: string
          enum: [character, copyright, species, rating, general]
        count:
          type: integer
          description: Number of posts where both tags appear together.
          example: 11182
        jaccard:
          type: number
          format: float
          minimum: 0
          maximum: 1
          description: Jaccard similarity (intersection / union).
          example: 0.5406

    TrendingTag:
      type: object
      required: [tag, normalized, category, recent_count, total_count, trend_score]
      properties:
        tag:
          type: string
          description: Tag display name.
          example: 'character:sessyoin kiara (heaven''s hole)'
        normalized:
          type: string
          description: Normalized tag name (pass to `/search?tag=`).
          example: character_sessyoin_kiara_heaven_s_hole
        category:
          type: string
          enum: [character, copyright, species, rating, general]
        recent_count:
          type: integer
          description: Posts with this tag in the window.
          example: 29
        total_count:
          type: integer
          description: Total posts with this tag across the index.
          example: 46
        trend_score:
          type: number
          format: float
          description: Observed/expected ratio. Above 1.0 means trending.
          example: 36.0

    PlatformStats:
      type: object
      required: [slug, name, post_count]
      properties:
        slug:
          type: string
          example: pixiv
        name:
          type: string
          example: Pixiv
        post_count:
          type: integer
          description: Searchable posts on this platform (tagged, non-duplicate, non-deleted).
          example: 192480

    Error:
      type: object
      required: [ok, error]
      properties:
        ok:
          type: boolean
          const: false
        error:
          type: string
          example: Rate limit exceeded. Max 30 requests per 60s.

  responses:
    BadRequest:
      description: Missing or invalid parameters.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            ok: false
            error: 'Provide at least one of: nl, q, tag, artist, since, or color.'
    Unauthorized:
      description: Invalid or expired API key.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            ok: false
            error: Invalid or expired API key.
    InsufficientBalance:
      description: Insufficient balance (Usage tier only).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            ok: false
            error: Insufficient balance. Add credit to your account to continue.
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            ok: false
            error: Post not found.
    RateLimited:
      description: Rate limit exceeded.
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds to wait before retrying.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            ok: false
            error: 'Rate limit exceeded. Max 30 requests per 60s.'
