# midoriroll Catalog — Cloudflare integration contract

The catalog is deployed on Cloudflare Pages with a static frontend and a small Worker/D1 backend. The owner edits online and clicks Publish series. Routine edits do not require rebuilding or redeploying.

## Public URLs

- Catalog: https://midoriroll-catalog.pages.dev/
- Owner editor: https://midoriroll-catalog.pages.dev/admin/
- Feed: https://midoriroll-catalog.pages.dev/v1/series.json
- Shared genres: https://midoriroll-catalog.pages.dev/v1/genres.json
- Guide: https://midoriroll-catalog.pages.dev/integration/
- This file: https://midoriroll-catalog.pages.dev/integration-guide.md
- JSON Schema: https://midoriroll-catalog.pages.dev/catalog.schema.json
- Example: https://midoriroll-catalog.pages.dev/catalog-example.json

Public feeds allow cross-origin GET, require no API key or login, and cache for up to 30 seconds. Fetch with credentials omitted. Check the HTTP status and JSON before replacing application state. A failed request returns an error and must not be treated as an empty catalog.

## JSON and compatibility

The envelope remains `version: 1` and `schemaVersion: "1.1"`, with `genres` and `series` arrays. `genres` is the complete shared taxonomy, including unused names. Each published series has:

| Field | Contract |
| --- | --- |
| id | Permanent string matching `^[a-z0-9][a-z0-9-]{0,79}$`. Never replace IDs with titles or indexes. |
| title | Required text, up to 120 characters. Render safely as text. |
| rating | JSON number from 0 through 10. Zero is valid. Show Rating and score without a brand/source label. |
| year | Integer from 1880 through 2100, or null. |
| summary | Text, up to 500 characters; may be empty. |
| genres | Up to 16 genre names, each up to 40 characters. |
| posterUrl | Absolute HTTPS image URL on the same origin as the feed. Use unchanged. |
| trending / featured | Explicit booleans set in the editor. Missing means false. |
| sortOrder | Integer 0–9999. Lower numbers appear first. Missing means 0. |
| status | ongoing, completed, or upcoming. Missing means ongoing. |
| isDemo | Hide records where this is true in the production app. Missing means false. |
| publishedAt | ISO timestamp of the published snapshot, or null. |

All migrated series retain their original IDs. Episode seriesId values, title links, and browser-local Library IDs continue to match. Unknown extra fields should be ignored. The public feed omits rating-source and artwork-credit metadata. Use the existing `catalog-source.js` parser; preserve its same-origin HTTPS poster validation.

## Genres and collections

Build every genre filter from the union of top-level `feed.genres` and production `series[].genres`, deduplicated case-insensitively. Do not hardcode the genre dropdown. An unused registered genre is valid and should show an empty state. Genre-manager additions are available in the feed after save; names added on a series become public when that series is published.

Use `featured === true` for Featured and `trending === true` for Trending. A series can appear in both. Hide empty special rows. Order by sortOrder ascending, publishedAt descending, then id ascending. Apply genre and collection filters together, and compare ratings numerically for highest-rated sorting.

## Catalog and poster setup

`site/` contains the static HTML, CSS, and browser modules. `worker/index.js` handles the API; `worker/auth.js` handles editor sessions. `migrations/` contains schema-only D1 migrations. `data/catalog.json` and `data/seed.sql` capture the initial migration; they are not the live source after import. Rebuilding does not overwrite online edits in D1.

D1 stores private drafts, published snapshots, genres, revisions, and new poster uploads. Previously published posters are bundled assets checked against each series' publication state. There is no R2 dependency. Free-plan Worker/D1 usage limits still apply.

The editor accepts JPG, PNG, or WebP up to 15 MB. In the browser it center-crops to a 2:3 frame, resizes to 330 × 495 pixels, and encodes WebP at quality 0.84. No branding, credit, rating, text, or watermark is added to artwork. The API accepts WebP below 1 MB and checks its RIFF/WEBP signature.

Uploaded poster paths remain `/v1/posters/{seriesId}/{randomUUID}.webp`. The filename matches `^[a-f0-9-]{36}\.webp$`. Replacements receive new filenames so caches do not hide updates. Draft previews use the private `/api/admin/posters/` endpoint; do not use these URLs in the public app. Demo files use `/posters/demo/{slug}.webp`. Use returned public URLs unchanged, reserve a 2:3 frame, lazy-load below the fold, and show a placeholder after image errors.

## Owner workflow

Sign in at `/admin/` with the private catalog admin key supplied for this deployment. This is separate from the Cloudflare API token and Google sign-in. The session uses a Secure, HttpOnly, SameSite=Strict cookie. Never put the admin key or Cloudflare token in frontend source.

Save draft changes the private draft only. Publish series releases a snapshot to the public feed. Unpublish removes the series from the feed and denies uploaded public poster routes. Revision checks prevent one editor from overwriting changes saved in another session. Export backup downloads metadata, including private drafts, without poster binaries; keep those owner backups private. D1 export/Time Travel can back up the complete database.

## midoriroll source files

The main app uses Vite/JavaScript. Keep package.json, package-lock.json, index.html, app.js, catalog-source.js, episode-source.js, watch-player.js, styles.css, assets/, public/, and vendor/ together at the repository root. `catalog-source.js` parses and orders catalog data. `episode-source.js` validates the separate episode source. Both catalogUrl constants point to this Cloudflare feed. `app.js` renders collections, genre filters, ratings, and retries.

Run `npm ci`, `npm test`, `npm run dev`, and `npm run build`. Commit the source before `npm run export:source` so the AI Studio ZIP matches the deployed release. Deploy the generated dist/. The ZIP contains the same source and lockfile for GitHub/AI Studio.

```js
import { catalogUrl, parseCatalogFeed, catalogOrder } from './catalog-source.js';

async function loadCatalog() {
  const response = await fetch(catalogUrl, {
    credentials: 'omit', cache: 'no-cache',
    signal: AbortSignal.timeout(7000)
  });
  if (!response.ok) throw new Error(`Catalog HTTP ${response.status}`);
  const catalog = parseCatalogFeed(await response.json(), catalogUrl);
  return { ...catalog, series: catalog.series.sort(catalogOrder) };
}
```

Use try/catch in the UI. The parser skips bad/duplicate rows while keeping valid rows, and throws if every non-demo row is invalid. A successful series:[] is a valid empty catalog. Preserve the last good in-memory catalog after refresh failure and offer Retry. The app refreshes on opening, every 60 seconds while visible outside playback, and on returning to the tab after at least 30 seconds. Do not interrupt active playback or discard unsaved settings when refreshing.

## Episode/player file

Episodes remain a separate public JSON source configured by the viewer under Settings → Episode source. Do not replace that URL with the catalog feed or auto-connect a sample source. Match each episode's seriesId exactly to catalog id. Renaming a title does not change its ID. Preserve the existing episode schema, parser, and player adapter.

## Paste into AI Studio

Use https://midoriroll-catalog.pages.dev/integration-guide.md and https://midoriroll-catalog.pages.dev/catalog.schema.json as the catalog contract. Reuse catalog-source.js for parsing/ordering and set the catalogUrl constants in catalog-source.js and episode-source.js to https://midoriroll-catalog.pages.dev/v1/series.json. Fetch with credentials omitted, a 7-second timeout, and HTTP/JSON validation. Keep version:1 compatibility; ignore unknown fields. Use id as the permanent key and posterUrl unchanged. Populate all genre filters from top-level genres plus series genres, including unused names. Show Featured and Trending only from explicit booleans, ordered by sortOrder ascending, publishedAt descending, id ascending. Support combined genre/collection filters and numeric rating/title/recent sorts. Hide isDemo:true and legacy demo markers. Display Rating and score without midoriroll/source labels, poster credits, or added watermarks. Preserve 2:3 frames, lazy loading, and image placeholders. Default missing flags to false, order to 0, status to ongoing, and genres to []. Preserve the last good result after refresh errors; show Retry and distinguish empty feeds from failures. Preserve browser-local Library/preferences, all IDs, the separately configured episode source, and the existing player. Do not auto-connect an episode source. Render feed text safely. Keep root project files and verify npm ci, npm test, npm run dev, and npm run build before release.
