From a8e04429a51affa30cc6e31671f9bac5599f048c Mon Sep 17 00:00:00 2001 From: HEXXT Date: Thu, 18 Jun 2026 03:18:45 +0100 Subject: [PATCH 01/19] feat(examples): website browse/media pages, CLI TUI, and meta provider wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Browse and Media pages to the example website; route / now lands on Browse - Wire AnilistMeta, MalMeta, KitsuMeta (shared MappingClient) into server.mjs - Add examples/cli — React Ink TUI with browse, search, media info, and stream resolution - Extend api.ts with meta routes (metaSearch, metaInfo, metaContent, metaStream, metaBrowse) and matching TypeScript types - Add shared UI components (Button, Collapsible, Input, Select) - Update breadcrumb logic in Layout to cover the new meta-aware routing - Fix dom.test.ts: querySelector searches children, so wrap target in a parent element - Add RESTRUCTURE.md design-proposal document --- CLAUDE.md | 4 +- RESTRUCTURE.md | 438 +++++++ examples/cli/index.tsx | 1147 +++++++++++++++++ examples/cli/package-lock.json | 1144 ++++++++++++++++ examples/cli/package.json | 20 + examples/cli/tsconfig.json | 13 + examples/server.mjs | 11 +- examples/website/package-lock.json | 127 +- examples/website/package.json | 1 + examples/website/src/App.tsx | 12 +- examples/website/src/api.ts | 203 ++- examples/website/src/components/Layout.tsx | 114 +- examples/website/src/components/ui/Button.tsx | 55 + .../website/src/components/ui/Collapsible.tsx | 58 + examples/website/src/components/ui/Input.tsx | 23 + examples/website/src/components/ui/Select.tsx | 43 + examples/website/src/index.css | 44 +- examples/website/src/pages/Browse.tsx | 212 +++ examples/website/src/pages/Episodes.tsx | 137 +- examples/website/src/pages/Media.tsx | 374 ++++++ examples/website/src/pages/Search.tsx | 191 ++- examples/website/src/pages/Stream.tsx | 335 +++-- tests/dom.test.ts | 6 +- 23 files changed, 4427 insertions(+), 285 deletions(-) create mode 100644 RESTRUCTURE.md create mode 100644 examples/cli/index.tsx create mode 100644 examples/cli/package-lock.json create mode 100644 examples/cli/package.json create mode 100644 examples/cli/tsconfig.json create mode 100644 examples/website/src/components/ui/Button.tsx create mode 100644 examples/website/src/components/ui/Collapsible.tsx create mode 100644 examples/website/src/components/ui/Input.tsx create mode 100644 examples/website/src/components/ui/Select.tsx create mode 100644 examples/website/src/pages/Browse.tsx create mode 100644 examples/website/src/pages/Media.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 722d778..63232b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ The SDK has four layers, all wired around a single `HttpClient`: - `HttpClient` wraps `fetch`, layered with a per-host rate limiter (`RateLimiter`, with built-in policies for AniList/Jikan/Kitsu/MALSync/Anify/arm-server), an exponential-backoff retry that honors `Retry-After` (`withRetry` + `HttpRetryableError`), and a curl-based fallback transport. The fallback is encapsulated behind the `HttpTransport` interface (`CurlFallbackTransport` default, `FetchTransport` for runtimes without `child_process`). `AbortSignal` is composed end-to-end: the caller's signal plus the SDK's timeout signal both abort the in-flight fetch. - Rate limit + retry are on by default with sensible policies for the bundled catalogue APIs (`DEFAULT_RATE_LIMITS`, `DEFAULT_RETRY_STATUSES`). Disable per-instance via `disableRateLimit: true` and `retry: false`. - `HttpClient` supports two proxy routing modes (`prepend` puts the proxy in front of `host/path`; `query` passes the URL as a query param): `requestUrl(url)` is the single chokepoint for that rewrite. -- `DomRegistry` is a global single-parser registry. `BrowserDomParser` works in browsers; in Node, consumers (and the E2E tests' `beforeAll`) must shim `globalThis.DOMParser` via `linkedom` before any provider parses HTML. Providers call `DomRegistry.parse(html)`: they never touch `DOMParser` directly. +- `DomRegistry` is a global single-parser registry. `BrowserDomParser` works in browsers; in Node, `dom.ts` automatically registers `linkedom` (a direct dependency) as the `globalThis.DOMParser` shim on import — consumers and tests no longer need to do this manually. Providers call `DomRegistry.parse(html)`: they never touch `DOMParser` directly. `DomRegistry.register()` still accepts a custom parser and takes full precedence. - `HlsUtils.rewriteManifest` rewrites every URI line in an `.m3u8` (including `URI="…"` inside `#EXT-X-KEY` / `#EXT-X-MAP`) so chunk fetches go through the same proxy as the manifest fetch. ### Unified URN ID space @@ -75,7 +75,7 @@ The proxy base URL is derived from each incoming request's `Host` header (and `X `cache?: SdkCache` is an optional `{get, set}` interface (sync or async) that memoizes provider calls by namespaced keys: `search::`, `content::`, `stream:::`, `tracks:::`, `meta:search::`, `meta:info::`, `meta:content:::`, `meta:stream:<...>`, `meta:tracks:<...>`, `meta:browse:<...>`, plus mapping keys `mapping:::`. -`/tracks` returns **501** for providers without `fetchUnitTracks`. `/meta/browse` returns **501** when the meta provider doesn't implement the requested kind. The example `examples/server.mjs` wires a `new Map()` as the cache. +`/tracks` returns **501** for providers without `fetchUnitTracks`. `/meta/browse` returns **501** when the meta provider doesn't implement the requested kind. The example `examples/server.mjs` wires a `new Map()` as the cache and registers all content providers plus `AnilistMeta`, `MalMeta`, `KitsuMeta` (each with a shared `MappingClient`). The example website (`examples/website/`) demonstrates every SDK feature — browse, meta search, full `IMediaMetadata` display (characters, staff, relations, recommendations, external links, streaming episodes), cross-provider episode resolution, and downloads. The example CLI (`examples/cli/`) is a React Ink TUI (`npm start` from `examples/cli/`) with browse, meta search, media info with tabs, provider selection, episode list, and stream resolution screens. ## ESM import convention diff --git a/RESTRUCTURE.md b/RESTRUCTURE.md new file mode 100644 index 0000000..5cb810b --- /dev/null +++ b/RESTRUCTURE.md @@ -0,0 +1,438 @@ +# ani-sdk — Restructure Proposal + +A clean-slate redesign aimed at one thing: **a junior dev should be able to build a working anime/manga app in 10 minutes without reading source code.** + +The current SDK has a correct mental model — _catalogue → episodes → stream_ — buried under five layers of abstractions (transport, extractors, providers, meta, server), a URN string format, dual provider hierarchies (content vs meta), a manual `DOMParser` shim, a custom `CallOptions` bag threaded through everything, and ~40 named public exports. This document throws that away and proposes alternatives. + +The result we must preserve: **find a title → list episodes/chapters → get a playable stream URL or manga page URLs**, with optional metadata enrichment (AniList/MAL/Kitsu), optional HTTP server, optional proxy, optional cache. Nothing else is sacred. + +--- + +## Table of contents + +1. [Diagnosis: why the current SDK is hard](#1-diagnosis) +2. [Design principles](#2-design-principles) +3. [Option A — The fluent client (recommended)](#3-option-a--the-fluent-client) +4. [Option B — The query-spec client](#4-option-b--the-query-spec-client) +5. [Option C — Server-first / "headless backend"](#5-option-c--server-first) +6. [Option D — Pipelines and middleware](#6-option-d--pipelines-and-middleware) +7. [Cross-cutting ideas worth stealing](#7-cross-cutting-ideas) +8. [Concrete file layout (for Option A)](#8-file-layout) +9. [Migration & rollout sketch](#9-migration) + +--- + +## 1. Diagnosis + +What makes the current SDK feel like a PhD project: + +| Pain | What's happening | What it costs the user | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| **Two parallel "provider" worlds** | `BaseProvider` (sites) and `BaseMetadataProvider` (catalogues) are separate hierarchies that the user has to wire together via `MappingClient`. | "Do I want `AnilistMeta` or `AllmangaProvider`? Both? In what order?" | +| **Manual ID format** | URN strings `"anilist:21"`, `"mal:anime:21"`. Some helpers throw, others don't. Some accept legacy bare IDs. | Users have to learn a string format that is not enforced by the type system. | +| **DOMParser shim requirement** | Node consumers have to install `linkedom` and assign `globalThis.DOMParser` before any HTML provider runs. | Cryptic runtime errors on first use. | +| **CallOptions bag** | A single options shape carrying fields meaningful at different layers ("ignored where not meaningful"). | Users don't know which knob applies where; autocomplete shows everything. | +| **Three-step resolution** | `search → fetchContentUnits → resolveStream`, with `fetchUnitTracks` as a side path, and language-as-an-orthogonal axis on the third call. | Lots of ceremony for "give me episode 5 of Naruto." | +| **Transport layer is public** | `HttpClient`, `RateLimiter`, `HttpRetryableError`, `CurlFallbackTransport`, `FetchTransport`, `HlsUtils`, `DomRegistry` are all exported. | The "public surface" of the SDK is dominated by plumbing nobody needs. | +| **Server is bolted on** | `startServer` re-implements the SDK's surface as HTTP routes that mostly mirror the JS API one-to-one. | Two sources of truth (TS surface + REST routes) that drift. | + +Note what is **not** broken: the extractor pattern, the rate-limit-per-host policy, HLS manifest proxy rewriting, the mapping waterfall idea, the URN concept itself (just not as a hand-written string). These are _good ideas_ — they just don't need to be in the user's face. + +--- + +## 2. Design principles + +The redesign should obey these, even when it costs flexibility: + +1. **One API surface.** A user imports one thing and gets autocomplete to everything they'll ever do. Plumbing is not exported. +2. **Result is reachable in ≤2 calls.** Search and play should be one or two `await`s, not three plus a mapping client. +3. **IDs are objects, not strings.** No hand-formatted URNs at the user boundary. The library round-trips IDs as opaque tokens. +4. **Catalogue and source are one concept.** A "Source" is anything that can answer "do you have X, and can you give me Y about it?" — whether that's AniList (metadata only), MegaPlay (stream only), or AllManga (both). +5. **Sensible defaults that just work.** `createClient()` with zero arguments should be enough to build a working app in Node. No `DOMParser` shim, no rate-limit config, no proxy config. +6. **Server is generated, not hand-written.** If we expose 6 verbs, the server has 6 routes — derived from the API, not duplicated. +7. **Optional features stay optional and tree-shake.** Manga, metadata, server, proxy, downloads should each be optional sub-imports. +8. **Testability without mocking.** Keep the "no-mocks" E2E discipline. The shape of the API shouldn't make that harder. + +--- + +## 3. Option A — The fluent client + +**Recommended.** Highest UX gain for least implementation complexity. + +### The whole API in one screen + +```ts +import { createClient } from 'ani-sdk'; + +const client = createClient(); // ← that's it. Defaults for everything. + +// Find something +const results = await client.anime.search('frieren'); +// results: { id: MediaId, title: string, cover?: string, year?: number }[] + +// Get rich info +const info = await results[0].info(); +// info: { title, description, episodes, cover, banner, genres, ... } + +// List episodes (auto-merges metadata + source data) +const episodes = await results[0].episodes(); +// episodes: Episode[] — each has number, title, thumbnail, languages, isFiller + +// Play one +const stream = await episodes[4].stream({ language: 'sub', quality: '1080p' }); +// stream: { url, isHls, headers, subtitles } + +// Manga is symmetric +const manga = await client.manga.search('chainsaw man'); +const chapters = await manga[0].chapters(); +const pages = await chapters[0].pages(); +``` + +That is the **entire** primary surface. No `BaseProvider`, no `MappingClient`, no URNs visible, no `HttpClient`, no `CallOptions`. The user never picks a provider unless they want to. + +### Key moves that make this work + +#### 3.1 `MediaId` is an opaque object, not a URN string + +```ts +// Returned by search; never constructed by hand. +class MediaId { + // Internally carries { sources: Map, kind: 'anime'|'manga' } + // — but the user only sees opaque methods. + toJSON(): string; // serialize for storage / URL params + static fromJSON(s: string): MediaId; +} +``` + +A `MediaId` knows _all_ the IDs that map to the same title (AniList #21, MAL #21, AllManga `Vw9bN…`) the moment any one of them is resolved. This kills the mapping waterfall from the API surface — the client resolves lazily and caches on the ID itself. + +Compare to today: the user holds `"anilist:21"` and has to separately ask `MappingClient` to figure out the AllManga ID. The new design _attaches_ mappings to the ID. + +#### 3.2 Search returns "live" objects, not records + +The objects returned from `search` are not POJOs — they are thin handles with methods (`info()`, `episodes()`, `chapters()`). Each method memoizes. The user never says "okay, now pass the id to `fetchContentUnits`." + +If a user wants pure data (for serialization, Redux store, etc.) they call `.toJSON()` which returns the POJO shape. The class is just a UX wrapper. + +#### 3.3 Sources, not providers + +Replace the dual `BaseProvider` / `BaseMetadataProvider` hierarchy with **one** interface: + +```ts +interface Source { + id: string; // 'anilist', 'allmanga', 'megaplay' + kinds: ('anime' | 'manga')[]; + capabilities: { + search?: true; + info?: true; // describe a title + units?: true; // list episodes/chapters + stream?: true; // resolve to a playable URL + browse?: true; // top/trending/seasonal + }; + // Implementation methods are internal; the registry composes them. +} +``` + +The client maintains two ordered lists per kind: `metadataSources` (preferred for info & search ranking) and `playbackSources` (preferred for streams). On `episodes()` the client _fuses_ the best metadata source's per-episode data with the best playback source's actual list, keyed by episode number. No `MappingClient` in the user's face — it's an implementation detail of the registry. + +#### 3.4 Configuration is layered, all optional + +```ts +const client = createClient({ + // 1. zero config: pick the bundled defaults + // 2. small config: pick which sources to enable + sources: ['anilist', 'mal', 'allmanga', 'mangadex'], + // 3. medium config: knobs + http: { proxy: 'https://proxy.example.com', timeoutMs: 15_000 }, + cache: new Map(), + // 4. power-user: inject custom sources + extend: [myCustomSource], +}); +``` + +No `HttpClient` constructor exposed to the user. No `DOMParser` shim — the SDK bundles a minimal HTML parser (or auto-detects `linkedom` if present, or ships a `parse5`-based fallback). + +#### 3.5 `Stream` is a smart object, not a payload + +```ts +const stream = await episode.stream(); + +stream.url; // direct URL (proxied if proxy is on) +stream.subtitles; // ISubtitleTrack[] +stream.qualities; // [{ quality, url }] for HLS variants +stream.headers; // playback headers (Referer, etc.) + +await stream.download('out.mp4'); // built-in download +const blob = await stream.toBlob(); // for browser MediaSource +const hls = stream.hlsManifest({ proxy }); // rewritten m3u8 text +``` + +Today's `IVideoPayload[]` + `HlsUtils` + `download/index.ts` collapse into one object. The user does _not_ learn three modules. + +#### 3.6 Streaming progressive results + +`search` returns `AsyncIterable` _and_ implements `.then` (i.e. it's both a `PromiseLike` and an iterable). Apps that want a Spotify-style "results as they arrive" UX iterate; apps that just want the array `await` it. + +```ts +for await (const hit of client.anime.search('one piece')) { + showInUI(hit); // appears as each source responds +} +// or: +const all = await client.anime.search('one piece'); // waits for everyone +``` + +#### 3.7 Errors are typed, not strings + +```ts +import { AniError, AniErrorCode } from 'ani-sdk'; + +try { + await episode.stream(); +} catch (e) { + if (e instanceof AniError) { + switch (e.code) { + case AniErrorCode.SourceUnavailable: ... + case AniErrorCode.RegionBlocked: ... + case AniErrorCode.NoStream: ... + case AniErrorCode.RateLimited: ... + } + } +} +``` + +No more "did this throw because of network or because the upstream changed its DOM?" + +--- + +## 4. Option B — The query-spec client + +For users who think in _what they want_ rather than _which methods to call_. Trades fluency for declarativeness; works well for server-side / batch use. + +```ts +import { ani } from 'ani-sdk'; + +const result = await ani({ + query: 'frieren', + kind: 'anime', + episode: 5, + language: 'sub', + quality: '1080p', + include: ['info', 'subtitles', 'related'], +}); + +// result: { info, episode: { stream, subtitles }, related: [...] } +``` + +One function. The SDK figures out which sources to hit. Good for cron jobs ("download the latest episode of every anime in my list every Tuesday at 3am") and for LLM tool-calling (one schema, every operation). + +**Composes well with Option A:** ship Option A as the primary API, ship `ani(spec)` as a sugar wrapper for the 80% case. + +--- + +## 5. Option C — Server-first + +The observation: a real chunk of users are building _streaming sites_, where the SDK runs on the server and the browser talks JSON. Today that's done via `startServer(...)` bolted on top of the JS API. + +Flip it. Make the **HTTP API the primary contract**, and ship two clients on top: + +``` +ani-server/ ← node service, single binary, configurable via env vars +ani-sdk-node/ ← thin wrapper around fetch() to ani-server +ani-sdk-browser/ ← same, for browsers +``` + +```ts +// Browser +import { createClient } from 'ani-sdk-browser'; +const client = createClient({ baseUrl: 'https://my-ani-server.example' }); +await client.anime.search('frieren'); +``` + +Pros: + +- The hard parts (DOM parsing, ffmpeg, rate-limit state, cookies) live in **one** place. +- One OpenAPI spec drives both clients; no drift. +- A user with a Vercel/Cloudflare account can `deploy → done` and have a working backend in minutes. +- Tighter security: scraping credentials/cookies never reach the browser. + +Cons: + +- Users who _want_ a pure library (Electron apps, CLIs) now run an embedded server. Workable (start it in-process) but heavier. + +**Hybrid:** ship Option A's classes as the in-process API, and have `createClient({ baseUrl })` _also_ exist as a drop-in remote variant. Same surface, two transports. + +--- + +## 6. Option D — Pipelines and middleware + +For library-builders rather than app-builders. Every operation is a pipeline of pure functions; the user composes their own client out of stages. + +```ts +import { pipeline, sources, transforms, sinks } from 'ani-sdk'; + +const myClient = pipeline() + .search(sources.anilist()) + .info(sources.anilist()) + .episodes(sources.allmanga()) + .stream(sources.allmanga()) + .use(transforms.rateLimit({ perHost: '1rps' })) + .use(transforms.cache(new Map())) + .use(transforms.proxy({ base: 'https://p.example' })) + .use(transforms.observability(myLogger)) + .build(); +``` + +Pros: extremely testable, every stage is pure, easy to slot in a new source as a one-liner. +Cons: more concepts up front; verges back into "PhD territory" if not careful. + +Best treated as a **power-user escape hatch under Option A**, not the primary surface. + +--- + +## 7. Cross-cutting ideas worth stealing + +These can layer onto any of the options above. Some are speculative. + +### 7.1 Source health & auto-failover + +The client tracks per-source rolling success rate. If `allmanga` 5xxs three times in a row, the next `episodes()` call silently tries `gogoanime` first. Today the user picks a provider and lives with its failures. + +```ts +client.health(); // { allmanga: 'ok', gogoanime: 'degraded', goyabu: 'down' } +``` + +### 7.2 Watch-state as a first-class concept + +Many apps need progress tracking. Today the SDK punts entirely. + +```ts +client.progress.set(episode, { positionSec: 1432, completed: false }); +const next = client.progress.continueWatching(); // sorted by recency +``` + +Storage is pluggable (`progressStore?: ProgressStore`), defaulting to in-memory. This is a 100-line module and would save every consumer from writing it. + +### 7.3 Subscriptions: "tell me when episode 8 is out" + +```ts +const sub = client.subscribe(media, { onNewEpisode: (ep) => ... }); +// internally: cheap polling against the cheapest source's episode-count endpoint +sub.cancel(); +``` + +Pairs well with Option C (server-first) where one node polls and broadcasts. + +### 7.4 LLM/agent-ready tool schema + +Ship `client.tools` as a Zod / JSON-Schema description of every method, so any agent framework can drop the SDK in without a wrapper. + +```ts +import { tools } from 'ani-sdk/tools'; +agent.useTools(tools); // ready for Anthropic / OpenAI tool-use +``` + +### 7.5 Offline-first manga reader + +Manga is the easier case (pages are static images). Ship a `downloadChapter()` that returns a CBZ blob, and a `client.library` for managing downloaded volumes. Most readers want this and write it themselves badly. + +### 7.6 First-class request-tracing + +A debug mode that emits one event per upstream call (URL, source, duration, cache-hit) so consumers can wire it into Sentry/Datadog without reverse-engineering the SDK. + +```ts +createClient({ trace: (e) => console.log(e.source, e.url, e.durationMs) }); +``` + +### 7.7 Schema-first source manifests + +A source is a JSON file describing its endpoints + a small parser script (TS plugin). This makes it possible for non-maintainers to PR new sources without touching the core, and to ship sources over-the-air (a `client.refreshSources()` that pulls a signed manifest). + +Trade-off: increases attack surface; would need code signing. + +### 7.8 Smart cache, not dumb cache + +Today's `SdkCache` is `{get, set}` and the caller picks TTL. Better: the SDK declares per-method semantics (search results: 1h; episode lists: 6h; stream URLs: _do not cache, they're signed_; metadata: 24h) and exposes those defaults — caller can override per-namespace. + +### 7.9 Versioned source contracts + +Bake the source's expected response shape into the source itself, and have CI run live snapshots nightly. When a source's parser silently starts returning empty arrays, a CI bot opens an issue. The "screenshot-the-frame" E2E discipline is good — formalize it. + +--- + +## 8. File layout + +For **Option A** (recommended), the source tree shrinks dramatically: + +``` +src/ + index.ts # public surface: createClient, types, errors + client.ts # the Client class and its facets (anime/manga) + media.ts # MediaId, MediaResult, Episode, Stream classes + errors.ts # AniError + codes + http.ts # internal fetch wrapper (NOT exported) + registry.ts # source registry + fusion logic + sources/ + base.ts # the single Source interface + anilist.ts + mal.ts + kitsu.ts + allmanga.ts + megaplay.ts + mangadex.ts + ... + extractors/ # still useful internally, NOT exported + base.ts + mp4upload.ts + blogger.ts + hls.ts + internal/ + rate-limit.ts + retry.ts + dom.ts # bundles a default parser; no shim required + proxy.ts # URL rewriting + HLS manifest rewriting + cache.ts + server/ # optional sub-import: 'ani-sdk/server' + index.ts # auto-generated from client surface + tools/ # optional sub-import: 'ani-sdk/tools' (LLM schema) +``` + +**~10 files vs. today's 40+.** Public surface goes from ~40 exports to ~6 (`createClient`, `AniError`, `AniErrorCode`, a couple of types like `MediaResult`, `Episode`, `Stream`). + +`package.json` exports: + +```jsonc +{ + "exports": { + ".": "./dist/index.js", + "./server": "./dist/server/index.js", + "./tools": "./dist/tools/index.js", + "./sources": "./dist/sources/index.js", // for power users + }, +} +``` + +--- + +## 9. Migration + +Not part of the brief, but for reference if Option A is picked: + +1. **Adapter month.** Ship `createClient()` alongside the existing exports; new code uses the new API, old code keeps working. +2. **Deprecate.** Mark every existing export `@deprecated` with a migration hint pointing to the new equivalent. +3. **Major bump.** Remove deprecated exports in `2.0`. Provide a one-page migration guide ("`new AllmangaProvider(http)` → `createClient({ sources: ['allmanga'] }).anime`"). +4. **Source contributions don't change much.** Today a contributor writes a `BaseProvider` subclass; tomorrow they write a `Source` object. The HTML/JSON parsing logic ports almost verbatim — what changes is the wrapper. + +--- + +## TL;DR — pick one + +| If you want… | Pick | +| ---------------------------------------------------- | ---------------------------------------- | +| Best DX for app builders, smallest surface | **Option A** | +| Tiny LLM-tool/cron-job ergonomics | Option B (as sugar on top of A) | +| Multi-tenant SaaS, browser apps, "one binary deploy" | Option C (with A as in-process fallback) | +| Building your own framework on top | Option D (as escape hatch under A) | + +**Recommendation:** Build **Option A**, expose **Option B** as a one-function sugar wrapper, and structure the internals so **Option C** is a 200-line additional package later. Skip **Option D** until a real power user asks for it. + +The whole redesign is one idea repeated everywhere: **the user should be able to forget that the SDK has layers.** diff --git a/examples/cli/index.tsx b/examples/cli/index.tsx new file mode 100644 index 0000000..5e37ebc --- /dev/null +++ b/examples/cli/index.tsx @@ -0,0 +1,1147 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { render, Box, Text, useInput, useApp } from 'ink'; +import TextInput from 'ink-text-input'; +import { + HttpClient, + AllmangaProvider, + AnikotoProvider, + AnimeParadiseProvider, + GogoanimeProvider, + MegaPlayProvider, + MangadexProvider, + WeebcentralProvider, + AnilistMeta, + MalMeta, + MappingClient, + type IMetaSearchResult, + type IMediaMetadata, + type IContentUnit, + type ResolvedMediaStream, + type IVideoPayload, +} from '../../dist/index.js'; + +// ─── SDK setup ──────────────────────────────────────────────────────────────── + +const http = new HttpClient({ timeoutMs: 30_000 }); +const mapping = new MappingClient(http); + +const META_PROVIDERS = { + anilist: new AnilistMeta(http, { mappingClient: mapping }), + mal: new MalMeta(http, { mappingClient: mapping }), +} as const; +type MetaProviderId = keyof typeof META_PROVIDERS; + +const CONTENT_PROVIDERS = { + allmanga: new AllmangaProvider(http), + anikoto: new AnikotoProvider(http), + animeparadise: new AnimeParadiseProvider(http), + gogoanime: new GogoanimeProvider(http), + megaplay: new MegaPlayProvider(http), + mangadex: new MangadexProvider(http), + weebcentral: new WeebcentralProvider(http), +} as const; +type ContentProviderId = keyof typeof CONTENT_PROVIDERS; + +const CONTENT_PROVIDER_IDS = Object.keys(CONTENT_PROVIDERS) as ContentProviderId[]; +const META_PROVIDER_IDS = Object.keys(META_PROVIDERS) as MetaProviderId[]; +const BROWSE_KINDS = ['trending', 'popular', 'seasonal', 'top'] as const; +type BrowseKind = (typeof BROWSE_KINDS)[number]; + +// ─── Screen state ───────────────────────────────────────────────────────────── + +type Screen = + | { type: 'home' } + | { + type: 'browse'; + kind: BrowseKind; + metaProvider: MetaProviderId; + loading: boolean; + items: IMetaSearchResult[]; + error: string | null; + } + | { type: 'search'; metaProvider: MetaProviderId } + | { type: 'results'; items: IMetaSearchResult[]; query: string; metaProvider: MetaProviderId } + | { + type: 'media'; + info: IMediaMetadata; + tab: 'overview' | 'chars' | 'staff' | 'rels'; + metaProvider: MetaProviderId; + } + | { type: 'provider-select'; info: IMediaMetadata; metaProvider: MetaProviderId } + | { + type: 'episodes'; + info: IMediaMetadata; + contentProvider: ContentProviderId; + units: IContentUnit[]; + loading: boolean; + error: string | null; + } + | { + type: 'stream'; + info: IMediaMetadata; + unit: IContentUnit; + contentProvider: ContentProviderId; + result: ResolvedMediaStream | null; + loading: boolean; + error: string | null; + }; + +// ─── Shared helpers ─────────────────────────────────────────────────────────── + +function preferredTitle(t: IMediaMetadata['title'] | IMetaSearchResult['title']): string { + return t.english ?? t.romaji ?? t.userPreferred ?? t.native ?? '(untitled)'; +} + +function stripHtml(html: string): string { + return html + .replace(//gi, '\n') + .replace(/<[^>]*>/g, '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/ /g, ' ') + .trim(); +} + +function truncate(s: string, n: number): string { + return s.length > n ? s.slice(0, n - 1) + '…' : s; +} + +// ─── Scrollable select list ─────────────────────────────────────────────────── + +interface SelectListProps { + items: T[]; + active: number; + renderItem: (item: T, isActive: boolean, index: number) => React.ReactNode; + maxVisible?: number; +} + +function SelectList({ items, active, renderItem, maxVisible = 10 }: SelectListProps) { + const start = Math.max(0, active - maxVisible + 3); + const visible = items.slice(start, start + maxVisible); + return ( + + {visible.map((item, i) => ( + {renderItem(item, start + i === active, start + i)} + ))} + + ); +} + +// ─── Divider ───────────────────────────────────────────────────────────────── + +function Divider({ label }: { label?: string }) { + const line = '─'.repeat(label ? 2 : 50); + return ( + + + {label ? `─── ${label} ${'─'.repeat(Math.max(0, 46 - label.length))}` : '─'.repeat(50)} + + + ); +} + +// ─── Status bar ────────────────────────────────────────────────────────────── + +function StatusBar({ hints }: { hints: string }) { + return ( + + + {hints} + + + ); +} + +// ─── Home screen ───────────────────────────────────────────────────────────── + +const HOME_ITEMS = [ + { label: 'Browse Trending', action: 'browse-trending' }, + { label: 'Browse Popular', action: 'browse-popular' }, + { label: 'Browse Seasonal', action: 'browse-seasonal' }, + { label: 'Search (meta)', action: 'search' }, + { label: 'Quit', action: 'quit' }, +] as const; + +function HomeScreen({ onSelect }: { onSelect: (action: string) => void }) { + const [active, setActive] = useState(0); + const { exit } = useApp(); + + useInput((input, key) => { + if (key.upArrow) setActive((i) => Math.max(0, i - 1)); + if (key.downArrow) setActive((i) => Math.min(HOME_ITEMS.length - 1, i + 1)); + if (key.return) { + const item = HOME_ITEMS[active]; + if (item.action === 'quit') exit(); + else onSelect(item.action); + } + if (input === 'q') exit(); + }); + + return ( + + + + anime-sdk + + — React Ink TUI + + + + {HOME_ITEMS.map((item, i) => ( + + + {i === active ? '► ' : ' '} + {item.label} + + + ))} + + + + + ); +} + +// ─── Browse screen ──────────────────────────────────────────────────────────── + +function BrowseScreen({ + kind, + metaProvider, + onSelect, + onBack, +}: { + kind: BrowseKind; + metaProvider: MetaProviderId; + onSelect: (item: IMetaSearchResult) => void; + onBack: () => void; +}) { + const [active, setActive] = useState(0); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + setLoading(true); + setError(null); + const provider = META_PROVIDERS[metaProvider]; + provider + .browse(kind, { catalogType: 'ANIME', perPage: 20 }) + .then(setItems) + .catch((e: Error) => setError(e.message)) + .finally(() => setLoading(false)); + }, [kind, metaProvider]); + + useInput((input, key) => { + if (loading) return; + if (key.upArrow) setActive((i) => Math.max(0, i - 1)); + if (key.downArrow) setActive((i) => Math.min(items.length - 1, i + 1)); + if (key.return && items[active]) onSelect(items[active]); + if (key.escape || input === 'q') onBack(); + }); + + return ( + + + Browse / + + {kind} + + [{metaProvider}] + + + {loading && ( + + loading... + + )} + {error && ( + + {error} + + )} + {!loading && !error && ( + + { + const title = preferredTitle(item.title); + const score = item.score != null ? ` ★${(item.score / 10).toFixed(1)}` : ''; + const meta = [item.format, item.year].filter(Boolean).join(' '); + return ( + + + {isActive ? '► ' : ' '} + {truncate(title, 38)} + + {score && {score}} + {meta && ( + + {' '} + {meta} + + )} + + ); + }} + /> + + )} + + + + ); +} + +// ─── Search screen ──────────────────────────────────────────────────────────── + +function SearchScreen({ + metaProvider, + onResults, + onBack, +}: { + metaProvider: MetaProviderId; + onResults: (items: IMetaSearchResult[], query: string) => void; + onBack: () => void; +}) { + const [query, setQuery] = useState(''); + const [searching, setSearching] = useState(false); + const [error, setError] = useState(null); + + const doSearch = useCallback(async () => { + if (!query.trim()) return; + setSearching(true); + setError(null); + try { + const results = await META_PROVIDERS[metaProvider].search(query.trim()); + onResults(results, query.trim()); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSearching(false); + } + }, [query, metaProvider, onResults]); + + useInput((input, key) => { + if (key.escape) onBack(); + }); + + return ( + + + Search [ + + {metaProvider} + + ] + + + + + + + {searching && searching...} + {error && {error}} + + + + ); +} + +// ─── Results screen ─────────────────────────────────────────────────────────── + +function ResultsScreen({ + items, + query, + metaProvider, + onSelect, + onBack, +}: { + items: IMetaSearchResult[]; + query: string; + metaProvider: MetaProviderId; + onSelect: (item: IMetaSearchResult) => void; + onBack: () => void; +}) { + const [active, setActive] = useState(0); + + useInput((input, key) => { + if (key.upArrow) setActive((i) => Math.max(0, i - 1)); + if (key.downArrow) setActive((i) => Math.min(items.length - 1, i + 1)); + if (key.return && items[active]) onSelect(items[active]); + if (key.escape || input === 'q') onBack(); + }); + + return ( + + + Results for + + "{query}" + + ({items.length}) + + + + { + const title = preferredTitle(item.title); + const score = item.score != null ? ` ★${(item.score / 10).toFixed(1)}` : ''; + return ( + + + {isActive ? '► ' : ' '} + {truncate(title, 42)} + + {score && {score}} + + {' '} + {item.format ?? item.catalogType} + + + ); + }} + /> + + + + + ); +} + +// ─── Media info screen ──────────────────────────────────────────────────────── + +function MediaInfoScreen({ + info, + metaProvider, + onWatch, + onBack, +}: { + info: IMediaMetadata; + metaProvider: MetaProviderId; + onWatch: () => void; + onBack: () => void; +}) { + const [tab, setTab] = useState<'overview' | 'chars' | 'staff' | 'rels'>('overview'); + const [scroll, setScroll] = useState(0); + + const title = preferredTitle(info.title); + const desc = info.description ? stripHtml(info.description) : null; + + const tabs = [ + { key: 'overview' as const, label: 'Overview' }, + ...(info.characters?.length + ? [{ key: 'chars' as const, label: `Chars(${info.characters.length})` }] + : []), + ...(info.staff?.length + ? [{ key: 'staff' as const, label: `Staff(${info.staff.length})` }] + : []), + ...(info.relations?.length + ? [{ key: 'rels' as const, label: `Relations(${info.relations.length})` }] + : []), + ]; + + useInput((input, key) => { + if (key.escape || input === 'q') onBack(); + if (input === 'w' || key.return) onWatch(); + if (input === 'c' && info.characters?.length) setTab('chars'); + if (input === 's' && info.staff?.length) setTab('staff'); + if (input === 'r' && info.relations?.length) setTab('rels'); + if (input === 'o') setTab('overview'); + if (key.downArrow) setScroll((s) => s + 1); + if (key.upArrow) setScroll((s) => Math.max(0, s - 1)); + if (input === '\t') { + const idx = tabs.findIndex((t) => t.key === tab); + setTab(tabs[(idx + 1) % tabs.length].key); + setScroll(0); + } + }); + + const meta = [ + info.format, + info.year, + info.season, + info.status, + info.score != null ? `★${(info.score / 10).toFixed(1)}` : null, + info.episodeCount != null ? `${info.episodeCount} eps` : null, + info.chapterCount != null ? `${info.chapterCount} chapters` : null, + info.durationMinutes != null ? `${info.durationMinutes}min` : null, + ] + .filter(Boolean) + .join(' '); + + return ( + + {/* Header */} + + + {truncate(title, 60)} + + {info.title.romaji && info.title.romaji !== title && ( + + {truncate(info.title.romaji, 60)} + + )} + + + + {/* Meta row */} + + {meta} + {info.studios && info.studios.length > 0 && ( + + Studio: {info.studios.slice(0, 2).join(', ')} + + )} + {info.genres && info.genres.length > 0 && ( + + Genres: {info.genres.slice(0, 5).join(', ')} + + )} + + + + + {/* Tab bar */} + + {tabs.map((t) => ( + + {tab === t.key ? `[${t.label}]` : t.label} + + ))} + + + {/* Tab content */} + {tab === 'overview' && ( + + {desc && ( + + + {desc + .split('\n') + .slice(scroll, scroll + 6) + .join('\n')} + + + )} + {info.externalLinks && info.externalLinks.length > 0 && ( + + + Links:{' '} + {info.externalLinks + .slice(0, 5) + .map((l) => l.site) + .join(' · ')} + + + )} + {info.streamingEpisodes && info.streamingEpisodes.length > 0 && ( + + + Episodes metadata: {info.streamingEpisodes.length} entries available + + + )} + {info.mappings && ( + + + Mappings:{' '} + {[ + info.mappings.anilist != null && `AniList:${info.mappings.anilist}`, + info.mappings.mal != null && `MAL:${info.mappings.mal}`, + info.mappings.kitsu != null && `Kitsu:${info.mappings.kitsu}`, + ] + .filter(Boolean) + .join(' ')} + + + )} + + )} + + {tab === 'chars' && info.characters && ( + + {info.characters.slice(scroll * 3, scroll * 3 + 9).map((c) => { + const va = c.voiceActors?.find((v) => v.language === 'Japanese') ?? c.voiceActors?.[0]; + return ( + + {truncate(c.name, 22)} + + {c.role ?? ''} + + {va && ( + + — {truncate(va.name, 18)} + + )} + + ); + })} + + {scroll * 3 + 1}–{Math.min((scroll + 3) * 3, info.characters.length)} of{' '} + {info.characters.length} + + + )} + + {tab === 'staff' && info.staff && ( + + {info.staff.slice(scroll, scroll + 10).map((s) => ( + + {truncate(s.name, 24)} + {s.role && ( + + {s.role} + + )} + + ))} + + )} + + {tab === 'rels' && info.relations && ( + + {info.relations.slice(scroll, scroll + 8).map((r) => ( + + [{r.relationType}] + {truncate(preferredTitle(r.title), 32)} + + {r.format ?? r.catalogType} + + + ))} + + )} + + + + + ); +} + +// ─── Provider select screen ─────────────────────────────────────────────────── + +function ProviderSelectScreen({ + info, + onSelect, + onBack, +}: { + info: IMediaMetadata; + onSelect: (id: ContentProviderId) => void; + onBack: () => void; +}) { + const [active, setActive] = useState(0); + const title = preferredTitle(info.title); + + // Filter to anime or manga providers based on catalogType + const relevant = CONTENT_PROVIDER_IDS.filter((id) => { + const isManga = info.catalogType === 'MANGA'; + const mangaProviders = ['mangadex', 'weebcentral']; + return isManga ? mangaProviders.includes(id) : !mangaProviders.includes(id); + }); + + useInput((input, key) => { + if (key.upArrow) setActive((i) => Math.max(0, i - 1)); + if (key.downArrow) setActive((i) => Math.min(relevant.length - 1, i + 1)); + if (key.return) onSelect(relevant[active]); + if (key.escape || input === 'q') onBack(); + }); + + return ( + + + Select provider for + + {truncate(title, 30)} + + + + + {relevant.map((id, i) => ( + + + {i === active ? '► ' : ' '} + {id} + + + ))} + + + + + ); +} + +// ─── Episodes screen ────────────────────────────────────────────────────────── + +function EpisodesScreen({ + info, + contentProvider, + units, + onSelect, + onBack, +}: { + info: IMediaMetadata; + contentProvider: ContentProviderId; + units: IContentUnit[]; + onSelect: (unit: IContentUnit) => void; + onBack: () => void; +}) { + const [active, setActive] = useState(0); + const title = preferredTitle(info.title); + const isManga = info.catalogType === 'MANGA'; + + useInput((input, key) => { + if (key.upArrow) setActive((i) => Math.max(0, i - 1)); + if (key.downArrow) setActive((i) => Math.min(units.length - 1, i + 1)); + if (key.return && units[active]) onSelect(units[active]); + if (key.escape || input === 'q') onBack(); + }); + + return ( + + + + {truncate(title, 40)} + + + {' '} + — {units.length} {isManga ? 'chapters' : 'episodes'} [{contentProvider}] + + + + + { + const prefix = isManga ? 'Ch' : 'EP'; + const num = String(unit.number).padStart(3, '0'); + const flags = [unit.isFiller ? 'FILLER' : null, unit.isRecap ? 'RECAP' : null] + .filter(Boolean) + .join(' '); + return ( + + + {isActive ? '► ' : ' '} + {prefix}.{num} + + {flags && ( + + [{flags}] + + )} + + {truncate(unit.title, 34)} + + {unit.availableLanguages && ( + + {unit.availableLanguages.join('/')} + + )} + + ); + }} + /> + + + + + ); +} + +// ─── Stream result screen ───────────────────────────────────────────────────── + +function StreamResultScreen({ + info, + unit, + contentProvider, + result, + onBack, +}: { + info: IMediaMetadata; + unit: IContentUnit; + contentProvider: ContentProviderId; + result: ResolvedMediaStream; + onBack: () => void; +}) { + const [active, setActive] = useState(0); + const title = preferredTitle(info.title); + const isManga = result.type === 'manga'; + + const streams: IVideoPayload[] = result.type === 'video' ? result.streams : []; + + useInput((input, key) => { + if (key.escape || input === 'q') onBack(); + if (!isManga) { + if (key.upArrow) setActive((i) => Math.max(0, i - 1)); + if (key.downArrow) setActive((i) => Math.min(streams.length - 1, i + 1)); + } + }); + + return ( + + + + + {truncate(title, 38)} + + EP.{String(unit.number).padStart(3, '0')} + + + via {contentProvider} + + + + + {isManga && result.type === 'manga' && ( + + ✓ {result.pages.imageUrls.length} pages resolved + + {result.pages.imageUrls.slice(0, 5).map((url, i) => ( + + {i + 1}. {truncate(url, 60)} + + ))} + {result.pages.imageUrls.length > 5 && ( + + ... and {result.pages.imageUrls.length - 5} more + + )} + + {result.pages.headers && Object.keys(result.pages.headers).length > 0 && ( + + + Headers:{' '} + {Object.entries(result.pages.headers) + .map(([k, v]) => `${k}: ${v}`) + .join(', ')} + + + )} + + )} + + {!isManga && streams.length > 0 && ( + + + ✓ {streams.length} stream{streams.length > 1 ? 's' : ''} resolved + + + {streams.map((s, i) => ( + + + + {i === active ? '●' : '○'} [{s.isHLS ? 'HLS' : 'MP4'}] {s.quality} + {s.language ? ` ${s.language}` : ''} + + {s.subtitles && s.subtitles.length > 0 && ( + + {s.subtitles.length} sub{s.subtitles.length > 1 ? 's' : ''} + + )} + + + {' '} + {truncate(s.sourceUrl, 58)} + + {i === active && s.headers && Object.keys(s.headers).length > 0 && ( + + {' '}headers: {Object.keys(s.headers).join(', ')} + + )} + {i === active && s.subtitles && s.subtitles.length > 0 && ( + + {s.subtitles.slice(0, 3).map((sub, j) => ( + + {' '}sub [{sub.label}]: {truncate(sub.url, 48)} + + ))} + + )} + + ))} + + + )} + + + + + ); +} + +// ─── Root app ───────────────────────────────────────────────────────────────── + +function App() { + const [screen, setScreen] = useState({ type: 'home' }); + const [metaProvider, setMetaProvider] = useState('anilist'); + const [history, setHistory] = useState([]); + + const push = useCallback( + (next: Screen) => { + setHistory((h) => [...h, screen]); + setScreen(next); + }, + [screen], + ); + + const back = useCallback(() => { + const prev = history[history.length - 1]; + if (prev) { + setHistory((h) => h.slice(0, -1)); + setScreen(prev); + } + }, [history]); + + const loadMedia = useCallback( + async (item: IMetaSearchResult) => { + const loading: Screen = { + type: 'media', + info: null as unknown as IMediaMetadata, + tab: 'overview', + metaProvider, + }; + push(loading); + try { + const info = await META_PROVIDERS[metaProvider].fetchMediaInfo(item.id); + setScreen({ type: 'media', info, tab: 'overview', metaProvider }); + } catch (e) { + back(); + } + }, + [metaProvider, push, back], + ); + + const loadEpisodes = useCallback( + async (info: IMediaMetadata, contentProviderId: ContentProviderId) => { + const provider = CONTENT_PROVIDERS[contentProviderId]; + const loadingScreen: Screen = { + type: 'episodes', + info, + contentProvider: contentProviderId, + units: [], + loading: true, + error: null, + }; + push(loadingScreen); + try { + const units = await META_PROVIDERS[metaProvider].fetchContentUnits(info.id, provider); + setScreen({ ...loadingScreen, units, loading: false }); + } catch (e) { + setScreen({ + ...loadingScreen, + loading: false, + error: e instanceof Error ? e.message : String(e), + }); + } + }, + [metaProvider, push], + ); + + const resolveStream = useCallback( + async (info: IMediaMetadata, unit: IContentUnit, contentProviderId: ContentProviderId) => { + const loadingScreen: Screen = { + type: 'stream', + info, + unit, + contentProvider: contentProviderId, + result: null, + loading: true, + error: null, + }; + push(loadingScreen); + try { + const provider = CONTENT_PROVIDERS[contentProviderId]; + const lang = unit.availableLanguages?.[0] ?? 'sub'; + const result = await provider.resolveStream(unit.id, lang as 'sub' | 'dub' | 'raw'); + setScreen({ ...loadingScreen, result, loading: false }); + } catch (e) { + setScreen({ + ...loadingScreen, + loading: false, + error: e instanceof Error ? e.message : String(e), + }); + } + }, + [push], + ); + + if (screen.type === 'home') { + return ( + { + if (action === 'browse-trending') + push({ + type: 'browse', + kind: 'trending', + metaProvider, + loading: true, + items: [], + error: null, + }); + if (action === 'browse-popular') + push({ + type: 'browse', + kind: 'popular', + metaProvider, + loading: true, + items: [], + error: null, + }); + if (action === 'browse-seasonal') + push({ + type: 'browse', + kind: 'seasonal', + metaProvider, + loading: true, + items: [], + error: null, + }); + if (action === 'search') push({ type: 'search', metaProvider }); + }} + /> + ); + } + + if (screen.type === 'browse') { + return ( + + ); + } + + if (screen.type === 'search') { + return ( + + push({ type: 'results', items, query, metaProvider: screen.metaProvider }) + } + onBack={back} + /> + ); + } + + if (screen.type === 'results') { + return ( + + ); + } + + if (screen.type === 'media') { + if (!screen.info) { + return ( + + Loading media info... + + ); + } + return ( + + push({ type: 'provider-select', info: screen.info, metaProvider: screen.metaProvider }) + } + onBack={back} + /> + ); + } + + if (screen.type === 'provider-select') { + return ( + loadEpisodes(screen.info, id)} + onBack={back} + /> + ); + } + + if (screen.type === 'episodes') { + if (screen.loading) { + return ( + + Resolving episodes via {screen.contentProvider}... + + (cross-source mapping may take a few seconds) + + + ); + } + if (screen.error) { + return ( + + {screen.error} + Press Esc to go back + + ); + } + return ( + resolveStream(screen.info, unit, screen.contentProvider)} + onBack={back} + /> + ); + } + + if (screen.type === 'stream') { + if (screen.loading) { + return ( + + Resolving stream... + + ); + } + if (screen.error || !screen.result) { + return ( + + {screen.error ?? 'No result'} + Press Esc to go back + + ); + } + return ( + + ); + } + + return null; +} + +// Boot the app — need stdin in raw mode for key capture +const { stdin } = process; +if (stdin.isTTY) stdin.setRawMode(true); + +render(); diff --git a/examples/cli/package-lock.json b/examples/cli/package-lock.json new file mode 100644 index 0000000..c50d65f --- /dev/null +++ b/examples/cli/package-lock.json @@ -0,0 +1,1144 @@ +{ + "name": "anime-sdk-cli", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "anime-sdk-cli", + "version": "0.0.1", + "dependencies": { + "ink": "^5.1.0", + "ink-text-input": "^6.0.0", + "react": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "tsx": "^4.19.0", + "typescript": "^5.4.5" + } + }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", + "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=14.13.1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-toolkit": { + "version": "1.47.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz", + "integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz", + "integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==", + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.1.3", + "ansi-escapes": "^7.0.0", + "ansi-styles": "^6.2.1", + "auto-bind": "^5.0.1", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "cli-cursor": "^4.0.0", + "cli-truncate": "^4.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.22.0", + "indent-string": "^5.0.0", + "is-in-ci": "^1.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.29.0", + "scheduler": "^0.23.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^7.1.0", + "stack-utils": "^2.0.6", + "string-width": "^7.2.0", + "type-fest": "^4.27.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0", + "ws": "^8.18.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "react": ">=18.0.0", + "react-devtools-core": "^4.19.1" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ink-text-input": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", + "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "ink": ">=5", + "react": ">=18" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-in-ci": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", + "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" + } + } +} diff --git a/examples/cli/package.json b/examples/cli/package.json new file mode 100644 index 0000000..303b318 --- /dev/null +++ b/examples/cli/package.json @@ -0,0 +1,20 @@ +{ + "name": "anime-sdk-cli", + "version": "0.0.1", + "private": true, + "type": "module", + "description": "React Ink TUI for anime-sdk", + "scripts": { + "start": "tsx index.tsx" + }, + "dependencies": { + "ink": "^5.1.0", + "ink-text-input": "^6.0.0", + "react": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "tsx": "^4.19.0", + "typescript": "^5.4.5" + } +} diff --git a/examples/cli/tsconfig.json b/examples/cli/tsconfig.json new file mode 100644 index 0000000..98b6973 --- /dev/null +++ b/examples/cli/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "jsx": "react", + "jsxFactory": "React.createElement", + "jsxFragmentFactory": "React.Fragment", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + } +} diff --git a/examples/server.mjs b/examples/server.mjs index cb439db..eed3d1f 100644 --- a/examples/server.mjs +++ b/examples/server.mjs @@ -10,11 +10,15 @@ import { MangadexProvider, WeebcentralProvider, MangapillProvider, + AnilistMeta, + MalMeta, + KitsuMeta, + MappingClient, } from '../dist/index.js'; const http = new HttpClient({ timeoutMs: 30000 }); +const mapping = new MappingClient(http); -// simple cache const store = new Map(); const cache = { get: (key) => store.get(key), @@ -33,6 +37,11 @@ startServer({ new WeebcentralProvider(http), new MangapillProvider(http), ], + metaProviders: [ + new AnilistMeta(http, { mappingClient: mapping }), + new MalMeta(http, { mappingClient: mapping }), + new KitsuMeta(http, { mappingClient: mapping }), + ], port: Number(process.env.PORT ?? 3030), proxy: true, cache, diff --git a/examples/website/package-lock.json b/examples/website/package-lock.json index c894786..a7d4fec 100644 --- a/examples/website/package-lock.json +++ b/examples/website/package-lock.json @@ -8,6 +8,7 @@ "name": "anime-sdk-example-website", "version": "0.0.0", "dependencies": { + "@base-ui/react": "^1.5.0", "@tanstack/react-query": "^5.0.0", "hls.js": "^1.5.0", "react": "^19.0.0", @@ -258,6 +259,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -306,6 +316,66 @@ "node": ">=6.9.0" } }, + "node_modules/@base-ui/react": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.5.0.tgz", + "integrity": "sha512-z1gSAlced1yY+iM+mHDEtIkD8UI3Ebs52MuBPxvV6f5hRutk+xvCH/wuB7hDqDzK9JG5FoMz5nhrqtSs1wjt1A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@base-ui/utils": "0.2.9", + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@date-fns/tz": "^1.2.0", + "@types/react": "^17 || ^18 || ^19", + "date-fns": "^4.0.0", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@date-fns/tz": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "date-fns": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-x/PDDCYzoqPpjrdyb3VcyylTI2IjUXEtYDGi5foh7KsnmNJIIaVwA2GLgDH1dps1GgXiJbA60hM+AyuTfQzIvw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@floating-ui/utils": "^0.2.11", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -748,6 +818,44 @@ "node": ">=18" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1518,7 +1626,7 @@ "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1634,7 +1742,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -2266,6 +2374,12 @@ "react-dom": ">=16.8" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.61.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", @@ -2420,6 +2534,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", diff --git a/examples/website/package.json b/examples/website/package.json index f7c0233..f95543b 100644 --- a/examples/website/package.json +++ b/examples/website/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@base-ui/react": "^1.5.0", "@tanstack/react-query": "^5.0.0", "hls.js": "^1.5.0", "react": "^19.0.0", diff --git a/examples/website/src/App.tsx b/examples/website/src/App.tsx index 0d70a66..ab6faf8 100644 --- a/examples/website/src/App.tsx +++ b/examples/website/src/App.tsx @@ -1,17 +1,15 @@ import { Routes, Route, useLocation } from 'react-router-dom'; import { useEffect } from 'react'; import Layout from './components/Layout'; +import Browse from './pages/Browse'; import Search from './pages/Search'; +import Media from './pages/Media'; import Episodes from './pages/Episodes'; import Stream from './pages/Stream'; function ScrollToTop() { const { pathname, search } = useLocation(); - - useEffect(() => { - window.scrollTo(0, 0); - }, [pathname, search]); - + useEffect(() => window.scrollTo(0, 0), [pathname, search]); return null; } @@ -21,7 +19,9 @@ export default function App() { }> - } /> + } /> + } /> + } /> } /> } /> diff --git a/examples/website/src/api.ts b/examples/website/src/api.ts index 9624943..08a6a7f 100644 --- a/examples/website/src/api.ts +++ b/examples/website/src/api.ts @@ -7,6 +7,8 @@ const get = (path: string, params: Record) => return r.json(); }); +// ─── Content provider routes ───────────────────────────────────────────────── + export const search = (provider: string, q: string) => get('/search', { provider, q }); export const content = (provider: string, mediaId: string) => @@ -15,21 +17,82 @@ export const content = (provider: string, mediaId: string) => export const stream = (provider: string, unitId: string, language: string) => get('/stream', { provider, unitId, language }); -// Types matching IVideoPayload / ResolvedMediaStream from the SDK +export const tracks = (provider: string, unitId: string, language: string) => + get('/tracks', { provider, unitId, language }); + +// ─── Metadata routes ───────────────────────────────────────────────────────── + +export const metaSearch = (provider: string, q: string) => get('/meta/search', { provider, q }); + +export const metaInfo = (provider: string, id: string) => get('/meta/info', { provider, id }); + +export const metaContent = (provider: string, id: string, contentProvider: string) => + get('/meta/content', { provider, id, contentProvider }); + +export const metaStream = ( + provider: string, + id: string, + episode: number, + contentProvider: string, + language: string, +) => get('/meta/stream', { provider, id, episode: String(episode), contentProvider, language }); + +export const metaBrowse = ( + provider: string, + kind: string, + opts: { catalogType?: string; perPage?: number; season?: string; year?: number } = {}, +) => + get('/meta/browse', { + provider, + kind, + ...(opts.catalogType ? { catalogType: opts.catalogType } : {}), + ...(opts.perPage ? { perPage: String(opts.perPage) } : {}), + ...(opts.season ? { season: opts.season } : {}), + ...(opts.year ? { year: String(opts.year) } : {}), + }); + +// ─── Provider lists ─────────────────────────────────────────────────────────── + +export const CONTENT_PROVIDERS = [ + 'megaplay', + 'allmanga', + 'animeparadise', + 'anikoto', + 'gogoanime', + 'goyabu', + 'mangadex', + 'weebcentral', + 'mangapill', +] as const; + +export const META_PROVIDERS = ['anilist', 'mal', 'kitsu'] as const; + +export type MetaProvider = (typeof META_PROVIDERS)[number]; + +// ─── Content types ──────────────────────────────────────────────────────────── + export type Lang = 'sub' | 'dub' | 'raw'; export interface SearchResult { id: string; title: string; + thumbnailUrl?: string; catalogType: string; + providerId: string; availableLanguages?: Lang[]; + year?: number; } export interface Episode { id: string; title: string; number: number; - availableLanguages: Lang[]; + availableLanguages?: Lang[]; + thumbnailUrl?: string; + description?: string; + airDate?: string; + isFiller?: boolean; + isRecap?: boolean; } export interface SubtitleTrack { @@ -58,3 +121,139 @@ export interface ResolvedStream { streams?: VideoStream[]; pages?: MangaStream; } + +// ─── Metadata types ─────────────────────────────────────────────────────────── + +export interface MetaTitle { + romaji?: string; + english?: string; + native?: string; + userPreferred?: string; +} + +export interface MetaCover { + large?: string; + medium?: string; + color?: string; +} + +export interface MetaSearchResult { + id: string; + providerId: string; + catalogType: string; + title: MetaTitle; + cover?: MetaCover; + year?: number; + format?: string; + score?: number; + isAdult?: boolean; +} + +export interface MediaRelation { + id: string; + relationType: string; + catalogType: string; + format?: string; + status?: string; + title: MetaTitle; + cover?: MetaCover; +} + +export interface VoiceActor { + id: string; + name: string; + language?: string; + image?: MetaCover; +} + +export interface MediaCharacter { + id: string; + name: string; + role?: string; + image?: MetaCover; + voiceActors?: VoiceActor[]; +} + +export interface MediaStaff { + id: string; + name: string; + role?: string; + image?: MetaCover; +} + +export interface MediaRecommendation { + id: string; + catalogType: string; + format?: string; + title: MetaTitle; + cover?: MetaCover; + rating?: number; +} + +export interface ExternalLink { + site: string; + url: string; + language?: string; + type?: 'STREAMING' | 'INFO' | 'SOCIAL'; +} + +export interface StreamingEpisode { + number: number; + title?: string; + description?: string; + thumbnail?: string; + airDate?: string; + isFiller?: boolean; + isRecap?: boolean; +} + +export interface MediaMetadata { + id: string; + providerId: string; + catalogType: string; + title: MetaTitle; + description?: string; + cover?: MetaCover; + banner?: string; + status?: string; + format?: string; + episodeCount?: number; + chapterCount?: number; + durationMinutes?: number; + genres?: string[]; + tags?: string[]; + studios?: string[]; + year?: number; + season?: string; + startDate?: string; + endDate?: string; + score?: number; + trailer?: string; + isAdult?: boolean; + synonyms?: string[]; + relations?: MediaRelation[]; + characters?: MediaCharacter[]; + staff?: MediaStaff[]; + recommendations?: MediaRecommendation[]; + externalLinks?: ExternalLink[]; + streamingEpisodes?: StreamingEpisode[]; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +export function preferredTitle(t: MetaTitle): string { + return t.english ?? t.romaji ?? t.userPreferred ?? t.native ?? '(untitled)'; +} + +export function stripHtml(html: string): string { + return html + .replace(//gi, '\n') + .replace(/<[^>]*>/g, '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' ') + .trim(); +} diff --git a/examples/website/src/components/Layout.tsx b/examples/website/src/components/Layout.tsx index df82f08..0f23c85 100644 --- a/examples/website/src/components/Layout.tsx +++ b/examples/website/src/components/Layout.tsx @@ -7,27 +7,73 @@ interface Crumb { } function buildCrumbs(pathname: string, sp: URLSearchParams): Crumb[] { + const metaProvider = sp.get('meta'); + const metaId = sp.get('id'); const provider = sp.get('provider'); const title = sp.get('title'); const ep = sp.get('ep'); const type = sp.get('type'); - const crumbs: Crumb[] = [{ label: 'anime-sdk', href: `/` }]; + const crumbs: Crumb[] = [{ label: 'anime-sdk', href: '/' }]; - if (provider) { - const providerHref = `/?provider=${provider}`; - crumbs.push(pathname === '/' ? { label: provider } : { label: provider, href: providerHref }); + if (pathname === '/search') { + crumbs.push({ label: 'search' }); + return crumbs; } - if (title) { - const mid = sp.get('mid'); - const episodesHref = mid - ? `/episodes?provider=${provider}&mid=${encodeURIComponent(mid)}&title=${encodeURIComponent(title)}${type ? `&type=${type}` : ''}` - : undefined; - crumbs.push(pathname === '/episodes' ? { label: title } : { label: title, href: episodesHref }); + if (pathname === '/media') { + if (metaProvider) crumbs.push({ label: metaProvider, href: `/?meta=${metaProvider}` }); + if (title) crumbs.push({ label: title }); + return crumbs; } - if (ep) crumbs.push({ label: ep }); + if (pathname === '/episodes') { + if (metaProvider && metaId) { + crumbs.push({ label: metaProvider, href: `/?meta=${metaProvider}` }); + if (title) { + crumbs.push({ + label: title, + href: `/media?meta=${metaProvider}&id=${encodeURIComponent(metaId)}`, + }); + } + if (provider) crumbs.push({ label: provider }); + } else { + if (provider) crumbs.push({ label: provider, href: `/?provider=${provider}` }); + if (title) crumbs.push({ label: title }); + } + return crumbs; + } + + if (pathname === '/stream') { + const metaIdParam = sp.get('metaId'); + if (metaProvider && metaIdParam) { + crumbs.push({ label: metaProvider, href: `/?meta=${metaProvider}` }); + if (title) { + crumbs.push({ + label: title, + href: `/media?meta=${metaProvider}&id=${encodeURIComponent(metaIdParam)}`, + }); + } + const mid = sp.get('mid'); + if (provider && metaIdParam && title) { + crumbs.push({ + label: provider, + href: `/episodes?meta=${metaProvider}&id=${encodeURIComponent(metaIdParam)}&provider=${provider}&title=${encodeURIComponent(title)}&type=${type ?? 'ANIME'}`, + }); + } + } else { + if (provider) { + const epHref = `/episodes?provider=${provider}&mid=${encodeURIComponent(sp.get('mid') ?? '')}&title=${encodeURIComponent(title ?? '')}&type=${type ?? 'ANIME'}`; + crumbs.push({ label: provider, href: `/?provider=${provider}` }); + if (title) crumbs.push({ label: title, href: epHref }); + } + } + if (ep) crumbs.push({ label: ep }); + return crumbs; + } + + if (metaProvider) crumbs.push({ label: metaProvider }); + if (provider) crumbs.push({ label: provider }); return crumbs; } @@ -38,27 +84,35 @@ export default function Layout() { const crumbs = buildCrumbs(loc.pathname, sp); return ( -
+
-
- anime-sdk logo -
- {crumbs.map((c, i) => ( - - {i > 0 && /} - {c.href ? ( - - {c.label} - - ) : ( - {c.label} - )} - - ))} +
+
+ anime-sdk logo +
+ {crumbs.map((c, i) => ( + + {i > 0 && /} + {c.href ? ( + + {c.label} + + ) : ( + {c.label} + )} + + ))} +
+ + SEARCH +
diff --git a/examples/website/src/components/ui/Button.tsx b/examples/website/src/components/ui/Button.tsx new file mode 100644 index 0000000..70cfda8 --- /dev/null +++ b/examples/website/src/components/ui/Button.tsx @@ -0,0 +1,55 @@ +import { Button as BaseButton } from '@base-ui/react/button'; +import { type ReactNode } from 'react'; + +type Variant = 'ghost' | 'outline' | 'solid' | 'tab'; + +interface ButtonProps { + children: ReactNode; + onClick?: () => void; + disabled?: boolean; + active?: boolean; + variant?: Variant; + type?: 'button' | 'submit' | 'reset'; + className?: string; + title?: string; + href?: string; + target?: string; + rel?: string; +} + +const base = + 'cursor-pointer inline-flex items-center gap-1.5 text-[10px] tracking-widest uppercase transition-colors disabled:opacity-30 disabled:cursor-default'; + +const variants: Record string> = { + ghost: (a) => (a ? 'text-base-900' : 'text-base-450 hover:text-base-700'), + outline: (a) => + a + ? 'border border-base-450 bg-base-200 text-base-900' + : 'border border-base-250 text-base-550 hover:border-base-400 hover:text-base-750', + solid: (_) => 'border border-base-450 text-base-900 hover:bg-base-200', + tab: (a) => + a ? 'border-b border-base-450 text-base-900 pb-2' : 'text-base-450 hover:text-base-700 pb-2', +}; + +export function Button({ + children, + onClick, + disabled, + active, + variant = 'ghost', + type = 'button', + className = '', + title, +}: ButtonProps) { + return ( + + {children} + + ); +} diff --git a/examples/website/src/components/ui/Collapsible.tsx b/examples/website/src/components/ui/Collapsible.tsx new file mode 100644 index 0000000..7edb0c4 --- /dev/null +++ b/examples/website/src/components/ui/Collapsible.tsx @@ -0,0 +1,58 @@ +import { Collapsible } from '@base-ui/react/collapsible'; +import { type ReactNode, useState } from 'react'; + +interface SectionCollapsibleProps { + label: string; + count?: number; + defaultOpen?: boolean; + children: ReactNode; +} + +export function SectionCollapsible({ + label, + count, + defaultOpen = true, + children, +}: SectionCollapsibleProps) { + return ( + + + + {label} + {count != null && ({count})} + + + ▾ + + + +
{children}
+
+
+ ); +} + +interface ExpandableProps { + children: ReactNode[]; + limit?: number; + label?: string; +} + +export function Expandable({ children, limit = 12, label = 'items' }: ExpandableProps) { + const [expanded, setExpanded] = useState(false); + const shown = expanded ? children : children.slice(0, limit); + const hidden = children.length - limit; + return ( +
+ {shown} + {!expanded && hidden > 0 && ( + + )} +
+ ); +} diff --git a/examples/website/src/components/ui/Input.tsx b/examples/website/src/components/ui/Input.tsx new file mode 100644 index 0000000..577a610 --- /dev/null +++ b/examples/website/src/components/ui/Input.tsx @@ -0,0 +1,23 @@ +import { Input as BaseInput } from '@base-ui/react/input'; + +interface InputProps { + value: string; + onChange: (v: string) => void; + onSubmit?: () => void; + placeholder?: string; + className?: string; +} + +export function Input({ value, onChange, onSubmit, placeholder, className = '' }: InputProps) { + return ( + onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') onSubmit?.(); + }} + placeholder={placeholder} + className={`border-base-300 text-base-850 placeholder-base-350 focus:border-base-450 flex-1 border bg-transparent px-3 py-2 text-sm outline-none ${className}`} + /> + ); +} diff --git a/examples/website/src/components/ui/Select.tsx b/examples/website/src/components/ui/Select.tsx new file mode 100644 index 0000000..aefd82f --- /dev/null +++ b/examples/website/src/components/ui/Select.tsx @@ -0,0 +1,43 @@ +import { Select } from '@base-ui/react/select'; + +interface SelectOption { + value: string; + label: string; +} + +interface ComboboxProps { + value: string; + onValueChange: (v: string) => void; + options: SelectOption[]; + className?: string; +} + +export function Combobox({ value, onValueChange, options, className = '' }: ComboboxProps) { + const label = options.find((o) => o.value === value)?.label ?? value; + return ( + onValueChange(v ?? '')}> + + {label} + + + + + + {options.map((opt) => ( + + {opt.label} + + + ))} + + + + + ); +} diff --git a/examples/website/src/index.css b/examples/website/src/index.css index 44336e7..a4cfdc4 100644 --- a/examples/website/src/index.css +++ b/examples/website/src/index.css @@ -1,5 +1,32 @@ @import 'tailwindcss'; +@theme { + --color-base-0: #000000; + --color-base-50: #080808; + --color-base-100: #0f0f0f; + --color-base-150: #141414; + --color-base-200: #1a1a1a; + --color-base-250: #222222; + --color-base-300: #2a2a2a; + --color-base-350: #333333; + --color-base-400: #444444; + --color-base-450: #555555; + --color-base-500: #666666; + --color-base-550: #777777; + --color-base-600: #888888; + --color-base-650: #999999; + --color-base-700: #aaaaaa; + --color-base-750: #bbbbbb; + --color-base-800: #cccccc; + --color-base-850: #d0d0d0; + --color-base-900: #ffffff; + --color-accent: #4a9eff; + --color-filler: #7a6b4a; + --color-recap: #4a5a7a; + --color-danger: #7f1d1d; + --color-success: #2a6633; +} + * { border-radius: 0 !important; box-sizing: border-box; @@ -10,7 +37,7 @@ body { margin: 0; padding: 0; font-family: 'JetBrains Mono', ui-monospace, 'Courier New', monospace; - background: #0a0a0a; + background: var(--color-base-100); background-image: linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px), linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px); @@ -19,15 +46,24 @@ body { } ::selection { - background: #333; + background: var(--color-base-350); } ::-webkit-scrollbar { width: 4px; } ::-webkit-scrollbar-track { - background: #0a0a0a; + background: var(--color-base-100); } ::-webkit-scrollbar-thumb { - background: #2a2a2a; + background: var(--color-base-300); +} + +[data-collapsible-panel] { + overflow: hidden; + height: 0; + transition: height 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} +[data-collapsible-panel][data-open] { + height: var(--collapsible-panel-height); } diff --git a/examples/website/src/pages/Browse.tsx b/examples/website/src/pages/Browse.tsx new file mode 100644 index 0000000..5109eff --- /dev/null +++ b/examples/website/src/pages/Browse.tsx @@ -0,0 +1,212 @@ +import { useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import * as api from '../api'; +import { Button } from '../components/ui/Button'; +import { Input } from '../components/ui/Input'; +import { Combobox } from '../components/ui/Select'; + +const KINDS = ['trending', 'popular', 'seasonal', 'top'] as const; +type Kind = (typeof KINDS)[number]; + +const SEASONS = ['WINTER', 'SPRING', 'SUMMER', 'FALL'] as const; +const CURRENT_YEAR = new Date().getFullYear(); + +function CoverCard({ result, onClick }: { result: api.MetaSearchResult; onClick: () => void }) { + const title = api.preferredTitle(result.title); + const cover = result.cover?.large ?? result.cover?.medium; + const accent = result.cover?.color; + + return ( + + ); +} + +export default function Browse() { + const navigate = useNavigate(); + const [sp, setSp] = useSearchParams(); + + const metaProvider = (sp.get('meta') as api.MetaProvider) || 'anilist'; + const kind = (sp.get('kind') as Kind) || 'trending'; + const catalogType = sp.get('type') || 'ANIME'; + const season = sp.get('season') || ''; + const year = sp.get('year') ? Number(sp.get('year')) : undefined; + + const [searchInput, setSearchInput] = useState(''); + + const { data, isFetching, isError, error } = useQuery({ + queryKey: ['browse', metaProvider, kind, catalogType, season, year], + queryFn: () => + api.metaBrowse(metaProvider, kind, { + catalogType, + perPage: 24, + season: season || undefined, + year, + }), + staleTime: 5 * 60 * 1000, + }); + + const setParam = (key: string, value: string) => + setSp((prev) => { + const next = new URLSearchParams(prev); + next.set(key, value); + return next; + }); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + if (searchInput.trim()) + navigate(`/search?meta=${metaProvider}&q=${encodeURIComponent(searchInput.trim())}`); + }; + + const goMedia = (r: api.MetaSearchResult) => + navigate(`/media?meta=${metaProvider}&id=${encodeURIComponent(r.id)}`); + + const seasonOptions = [ + { value: '', label: 'season' }, + ...SEASONS.map((s) => ({ value: s, label: s })), + ]; + + const yearOptions = [ + { value: '', label: 'year' }, + ...Array.from({ length: 15 }, (_, i) => CURRENT_YEAR - i).map((y) => ({ + value: String(y), + label: String(y), + })), + ]; + + return ( +
+
+ { + if (searchInput.trim()) + navigate(`/search?meta=${metaProvider}&q=${encodeURIComponent(searchInput.trim())}`); + }} + placeholder="search anime, manga..." + /> + +
+ +
+
+ {api.META_PROVIDERS.map((p) => ( + + ))} +
+ +
+ {(['ANIME', 'MANGA'] as const).map((t) => ( + + ))} +
+
+ +
+ {KINDS.map((k) => ( + + ))} + + {kind === 'seasonal' && ( + <> + setParam('season', v)} + options={seasonOptions} + /> + { + const next = new URLSearchParams(sp); + if (v) next.set('year', v); + else next.delete('year'); + setSp(next); + }} + options={yearOptions} + /> + + )} +
+ + {isFetching &&

loading...

} + {isError &&

{String(error)}

} + + {data && ( +
+ {data.map((r) => ( + goMedia(r)} /> + ))} +
+ )} + + {data && data.length === 0 && ( +

no results for this combination

+ )} +
+ ); +} diff --git a/examples/website/src/pages/Episodes.tsx b/examples/website/src/pages/Episodes.tsx index 9ac2631..b0f12b1 100644 --- a/examples/website/src/pages/Episodes.tsx +++ b/examples/website/src/pages/Episodes.tsx @@ -1,4 +1,4 @@ -import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useNavigate, useSearchParams, Link } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import * as api from '../api'; @@ -6,66 +6,125 @@ export default function Episodes() { const navigate = useNavigate(); const [sp] = useSearchParams(); + const metaProvider = sp.get('meta') || ''; + const metaId = sp.get('id') || ''; + const provider = sp.get('provider') || ''; const mediaId = sp.get('mid') || ''; - const title = sp.get('title') || mediaId; + + const title = sp.get('title') || mediaId || metaId; const type = sp.get('type') || 'ANIME'; const isManga = type === 'MANGA'; - const unitLabel = isManga ? 'Chapter' : 'EP'; + const unitLabel = isManga ? 'Ch' : 'EP'; + + const isMeta = !!(metaProvider && metaId && provider); const { data, isFetching, isError, error } = useQuery({ - queryKey: ['content', provider, mediaId], - queryFn: () => api.content(provider, mediaId), - enabled: !!(provider && mediaId), + queryKey: isMeta + ? ['meta-content', metaProvider, metaId, provider] + : ['content', provider, mediaId], + queryFn: () => + isMeta ? api.metaContent(metaProvider, metaId, provider) : api.content(provider, mediaId), + enabled: isMeta ? !!(metaProvider && metaId && provider) : !!(provider && mediaId), }); - const goStream = (ep: api.Episode) => - navigate( - `/stream?provider=${provider}&uid=${encodeURIComponent(ep.id)}` + - `&title=${encodeURIComponent(title)}&ep=${encodeURIComponent(`${unitLabel}.${String(ep.number).padStart(3, '0')}`)}&mid=${encodeURIComponent(mediaId)}&type=${type}`, - ); + const goStream = (ep: api.Episode) => { + if (isMeta) { + navigate( + `/stream?provider=${provider}&uid=${encodeURIComponent(ep.id)}` + + `&title=${encodeURIComponent(title)}&ep=${encodeURIComponent(`${unitLabel}.${String(ep.number).padStart(3, '0')}`)}&mid=${encodeURIComponent(ep.id)}&type=${type}` + + `&meta=${metaProvider}&metaId=${encodeURIComponent(metaId)}`, + ); + } else { + navigate( + `/stream?provider=${provider}&uid=${encodeURIComponent(ep.id)}` + + `&title=${encodeURIComponent(title)}&ep=${encodeURIComponent(`${unitLabel}.${String(ep.number).padStart(3, '0')}`)}&mid=${encodeURIComponent(mediaId)}&type=${type}`, + ); + } + }; return (
-

{title}

+
+ {isMeta && ( + + ← info + + )} +

{title}

+
{data && ( -

+

{data.length} {isManga ? 'chapters' : 'episodes'} + {isMeta && via {provider}}

)}
- {isFetching &&

fetching...

} + {isFetching &&

fetching...

} {isError &&

{String(error)}

} {data && ( -
- {data.map((ep) => ( - - ))} +
+ {data.map((ep) => { + const hasThumb = !!ep.thumbnailUrl; + return ( + + ); + })}
)}
diff --git a/examples/website/src/pages/Media.tsx b/examples/website/src/pages/Media.tsx new file mode 100644 index 0000000..c0e0154 --- /dev/null +++ b/examples/website/src/pages/Media.tsx @@ -0,0 +1,374 @@ +import { useState } from 'react'; +import { useNavigate, useSearchParams, Link } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import * as api from '../api'; +import { Button } from '../components/ui/Button'; +import { Combobox } from '../components/ui/Select'; +import { SectionCollapsible, Expandable } from '../components/ui/Collapsible'; + +function PersonCard({ + name, + role, + image, + sub, +}: { + name: string; + role?: string; + image?: api.MetaCover; + sub?: string; +}) { + const src = image?.large ?? image?.medium; + return ( +
+
+ {src ? ( + {name} + ) : ( +
+ )} +
+

{name}

+ {role &&

{role}

} + {sub &&

{sub}

} +
+ ); +} + +function RelationCard({ rel }: { rel: api.MediaRelation }) { + const title = api.preferredTitle(rel.title); + const cover = rel.cover?.medium ?? rel.cover?.large; + return ( +
+ {cover ? ( + {title} + ) : ( +
+ )} +
+

{rel.relationType}

+

{title}

+

+ {[rel.format, rel.status].filter(Boolean).join(' · ')} +

+
+
+ ); +} + +export default function Media() { + const navigate = useNavigate(); + const [sp] = useSearchParams(); + + const metaProvider = sp.get('meta') || 'anilist'; + const id = sp.get('id') || ''; + + const [contentProvider, setContentProvider] = useState(api.CONTENT_PROVIDERS[0]); + + const { data, isFetching, isError, error } = useQuery({ + queryKey: ['meta-info', metaProvider, id], + queryFn: () => api.metaInfo(metaProvider, id), + enabled: !!(metaProvider && id), + staleTime: 10 * 60 * 1000, + }); + + if (isFetching) { + return ( +
+

loading...

+
+ ); + } + + if (isError || !data) { + return ( +
+

{isError ? String(error) : 'no data'}

+
+ ); + } + + const title = api.preferredTitle(data.title); + const isManga = data.catalogType === 'MANGA'; + const unitLabel = isManga ? 'Read' : 'Watch'; + + const watch = () => + navigate( + `/episodes?meta=${metaProvider}&id=${encodeURIComponent(id)}&provider=${contentProvider}&title=${encodeURIComponent(title)}&type=${data.catalogType}`, + ); + + const providerOptions = api.CONTENT_PROVIDERS.map((p) => ({ value: p, label: p })); + + return ( +
+ {data.banner && ( +
+ +
+
+ )} + +
+
+
+ {data.cover?.large ? ( + {title} + ) : ( +
+ )} +
+ +
+

{title}

+ {data.title.romaji && data.title.romaji !== title && ( +

{data.title.romaji}

+ )} + {data.title.native && ( +

{data.title.native}

+ )} + +
+ {data.status && {data.status}} + {data.format && {data.format}} + {data.year && {data.year}} + {data.season && {data.season}} + {data.score != null && ( + ★ {(data.score / 10).toFixed(1)} + )} +
+ +
+ {data.episodeCount != null && {data.episodeCount} eps} + {data.chapterCount != null && {data.chapterCount} chapters} + {data.durationMinutes != null && {data.durationMinutes}min} + {data.studios?.slice(0, 2).map((s) => ( + {s} + ))} +
+
+
+ + {data.genres && data.genres.length > 0 && ( +
+ {data.genres.map((g) => ( + + {g} + + ))} +
+ )} + + {data.description && ( +

+ {api.stripHtml(data.description).slice(0, 600)} + {api.stripHtml(data.description).length > 600 && '…'} +

+ )} + +
+ +
+ via + +
+
+ + {data.trailer && ( + + ▶ trailer ↗ + + )} +
+ +
+ {data.streamingEpisodes && data.streamingEpisodes.length > 0 && ( + + + {data.streamingEpisodes.map((ep) => ( +
+ {ep.thumbnail && ( + {ep.title + )} +
+
+ + EP.{String(ep.number).padStart(3, '0')} + + {ep.isFiller && ( + FILLER + )} + {ep.isRecap && ( + RECAP + )} +
+ {ep.title && ( +

{ep.title}

+ )} + {ep.airDate &&

{ep.airDate}

} +
+
+ ))} +
+
+ )} + + {data.externalLinks && data.externalLinks.length > 0 && ( + +
+ {data.externalLinks.map((link, i) => ( + + {link.site} + {link.language && ({link.language})} + + ))} +
+
+ )} + + {data.characters && data.characters.length > 0 && ( + + + {data.characters.map((c) => { + const va = + c.voiceActors?.find((v) => v.language === 'Japanese') ?? c.voiceActors?.[0]; + return ( +
+ +
+ ); + })} +
+
+ )} + + {data.staff && data.staff.length > 0 && ( + + + {data.staff.map((s) => ( +
+ +
+ ))} +
+
+ )} + + {data.relations && data.relations.length > 0 && ( + +
+ {data.relations.map((r) => ( + + ))} +
+
+ )} + + {data.recommendations && data.recommendations.length > 0 && ( + + + {data.recommendations.map((r) => { + const recTitle = api.preferredTitle(r.title); + const cover = r.cover?.large ?? r.cover?.medium; + return ( + +
+ {cover ? ( + {recTitle} + ) : ( +
+ )} + {r.rating != null && ( + + ★ {r.rating} + + )} +
+
+

+ {recTitle} +

+
+ + ); + })} + + + )} + + {data.tags && data.tags.length > 0 && ( + + + {data.tags.map((t, i) => ( + + {t} + {i < data.tags!.length - 1 ? ',' : ''} + + ))} + + + )} + + {data.synonyms && data.synonyms.length > 0 && ( + +
+ {data.synonyms.map((s, i) => ( +

+ {s} +

+ ))} +
+
+ )} +
+
+ ); +} diff --git a/examples/website/src/pages/Search.tsx b/examples/website/src/pages/Search.tsx index f2d4895..b9fb739 100644 --- a/examples/website/src/pages/Search.tsx +++ b/examples/website/src/pages/Search.tsx @@ -2,102 +2,183 @@ import { useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import * as api from '../api'; +import { Button } from '../components/ui/Button'; +import { Input } from '../components/ui/Input'; -const PROVIDERS = [ - 'megaplay', - 'allmanga', - 'animeparadise', - 'anikoto', - 'gogoanime', - 'goyabu', - 'mangadex', - 'weebcentral', - 'mangapill', -]; +type Mode = 'meta' | 'content'; export default function Search() { const navigate = useNavigate(); const [sp, setSp] = useSearchParams(); - const provider = sp.get('provider') || PROVIDERS[0]; + const mode = (sp.get('mode') as Mode) || (sp.get('meta') ? 'meta' : 'content'); + const metaProvider = (sp.get('meta') as api.MetaProvider) || 'anilist'; + const contentProvider = sp.get('provider') || api.CONTENT_PROVIDERS[0]; const initialQ = sp.get('q') || ''; const [input, setInput] = useState(initialQ); - const { data, isFetching, isError, error } = useQuery({ - queryKey: ['search', provider, initialQ], - queryFn: () => api.search(provider, initialQ), - enabled: !!initialQ, - }); + const setParam = (key: string, value: string) => + setSp((prev) => { + const next = new URLSearchParams(prev); + next.set(key, value); + return next; + }); const submit = (e: React.FormEvent) => { e.preventDefault(); setSp((prev) => { const next = new URLSearchParams(prev); - next.set('provider', provider); next.set('q', input); + next.set('mode', mode); return next; }); }; - const setProvider = (p: string) => - setSp((prev) => { - const next = new URLSearchParams(prev); - next.set('provider', p); - return next; - }); + const metaQuery = useQuery({ + queryKey: ['meta-search', metaProvider, initialQ], + queryFn: () => api.metaSearch(metaProvider, initialQ), + enabled: mode === 'meta' && !!initialQ, + }); - const goEpisodes = (result: api.SearchResult) => + const contentQuery = useQuery({ + queryKey: ['search', contentProvider, initialQ], + queryFn: () => api.search(contentProvider, initialQ), + enabled: mode === 'content' && !!initialQ, + }); + + const isFetching = mode === 'meta' ? metaQuery.isFetching : contentQuery.isFetching; + const isError = mode === 'meta' ? metaQuery.isError : contentQuery.isError; + const error = mode === 'meta' ? metaQuery.error : contentQuery.error; + + const goMedia = (r: api.MetaSearchResult) => + navigate(`/media?meta=${metaProvider}&id=${encodeURIComponent(r.id)}`); + + const goEpisodes = (r: api.SearchResult) => navigate( - `/episodes?provider=${provider}&mid=${encodeURIComponent(result.id)}&title=${encodeURIComponent(result.title)}&type=${result.catalogType}`, + `/episodes?provider=${contentProvider}&mid=${encodeURIComponent(r.id)}&title=${encodeURIComponent(r.title)}&type=${r.catalogType}`, ); return (
-
- {PROVIDERS.map((p) => ( - + {m === 'meta' ? 'Catalogue' : 'Provider'} + ))}
-
- + {mode === 'meta' + ? api.META_PROVIDERS.map((p) => ( + + )) + : api.CONTENT_PROVIDERS.map((p) => ( + + ))} +
+ + + setInput(e.target.value)} + onChange={setInput} + onSubmit={() => { + setSp((prev) => { + const next = new URLSearchParams(prev); + next.set('q', input); + next.set('mode', mode); + return next; + }); + }} placeholder="search title..." - className="flex-1 border border-[#2a2a2a] bg-transparent px-3 py-2 text-sm placeholder-[#2a2a2a] outline-none focus:border-[#444]" /> - + - {isFetching &&

fetching...

} + {isFetching &&

fetching...

} {isError &&

{String(error)}

} - {data && ( -
-
- RESULTS ({data.length}) + {mode === 'meta' && metaQuery.data && ( +
+
+ RESULTS ({metaQuery.data.length}) +
+ {metaQuery.data.map((r) => { + const title = api.preferredTitle(r.title); + const cover = r.cover?.medium ?? r.cover?.large; + return ( + + ); + })} +
+ )} + + {mode === 'content' && contentQuery.data && ( +
+
+ RESULTS ({contentQuery.data.length})
- {data.map((r) => ( + {contentQuery.data.map((r) => ( ))}
diff --git a/examples/website/src/pages/Stream.tsx b/examples/website/src/pages/Stream.tsx index 0b87507..1f4af44 100644 --- a/examples/website/src/pages/Stream.tsx +++ b/examples/website/src/pages/Stream.tsx @@ -1,10 +1,10 @@ import { useEffect, useRef, useState } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useNavigate, useSearchParams, Link } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import Hls from 'hls.js'; import * as api from '../api'; - -// ─── Download button with SSE progress ──────────────────────────────────────── +import { Combobox } from '../components/ui/Select'; +import { SectionCollapsible } from '../components/ui/Collapsible'; type DownloadPhase = 'idle' | 'active' | 'done' | 'error'; @@ -86,7 +86,7 @@ function DownloadButton({ return ( @@ -94,7 +94,7 @@ function DownloadButton({ } if (phase === 'done') { - return SAVED; + return SAVED; } if (phase === 'error') { @@ -102,20 +102,19 @@ function DownloadButton({ ); } - // active return (
- {label} + {label} @@ -129,9 +128,6 @@ function Player({ langUI, }: { stream: api.VideoStream; - /** Subtitle tracks to render — the caller hands these in so the selector can - * reflect what the SDK actually advertised for this unit (via `/tracks` and - * falling back to `stream.subtitles`). */ subtitles: api.SubtitleTrack[]; langUI?: React.ReactNode; }) { @@ -140,8 +136,6 @@ function Player({ const [hlsSubTracks, setHlsSubTracks] = useState<{ id: number; name: string; lang: string }[]>( [], ); - // -1 = off; 0..N-1 = external ; 1000+i = HLS track i (kept distinct so the - // two source sets don't collide). const [activeSub, setActiveSub] = useState(-1); const hlsRef = useRef(undefined); const externalSubs = subtitles; @@ -168,7 +162,6 @@ function Player({ lang: t.lang ?? '', })); setHlsSubTracks(tracks); - // Only auto-enable an HLS track if we don't already have an external one. if (tracks.length > 0 && externalSubs.length === 0) { hls!.subtitleTrack = 0; setActiveSub(1000); @@ -198,8 +191,6 @@ function Player({ }; }, [stream.sourceUrl, stream.isHLS, externalSubs.length]); - // Apply external-track selection imperatively: alone doesn't - // reliably enable a track across browsers, and toggling needs runtime control. useEffect(() => { const v = ref.current; if (!v) return; @@ -220,8 +211,8 @@ function Player({ if (playerError) { return ( -
-

{playerError}

+
+

{playerError}

); } @@ -232,7 +223,7 @@ function Player({ ref={ref} controls crossOrigin="anonymous" - className="mb-4 aspect-video w-full border border-[#1e1e1e] bg-black" + className="border-base-200 bg-base-0 mb-4 aspect-video w-full border" > {externalSubs.map((s, i) => ( {langUI} {hasSubtitleUI && ( -
- SUB - +
+ SUB + selectSub(Number(v))} + options={[ + { value: '-1', label: 'off' }, + ...externalSubs.map((s, i) => ({ value: String(i), label: s.label })), + ...hlsSubTracks.map((t) => ({ value: String(1000 + t.id), label: t.name })), + ]} + />
)}
@@ -275,7 +256,7 @@ function Player({ function MangaReader({ pages }: { pages: api.MangaStream }) { return ( -
+
{pages.imageUrls.map((url, i) => ( ('sub'); const [activeIdx, setActiveIdx] = useState(0); const [showEpisodes, setShowEpisodes] = useState(false); - // Episode list — language-agnostic, one call. Each episode advertises its - // own `availableLanguages`; we use the current episode's list to decide - // which LANG buttons make sense. const { data: episodes } = useQuery({ queryKey: ['content', provider, mediaId], queryFn: () => api.content(provider, mediaId), @@ -325,8 +303,6 @@ export default function Stream() { const currentEpisode = currentIdx >= 0 ? episodes![currentIdx] : null; const availableLangs = currentEpisode?.availableLanguages ?? ['sub']; - // If our current `lang` isn't one this episode supports, drop to the first - // language that *is* supported. useEffect(() => { if (availableLangs.length > 0 && !availableLangs.includes(lang)) { setLang(availableLangs[0]); @@ -347,29 +323,44 @@ export default function Stream() { const nextEp = currentIdx >= 0 && currentIdx < (episodes?.length ?? 0) - 1 ? episodes![currentIdx + 1] : null; - const goEpisode = (ep: api.Episode) => - navigate( + const goEpisode = (ep: api.Episode) => { + const base = `/stream?provider=${provider}&uid=${encodeURIComponent(ep.id)}` + - `&title=${encodeURIComponent(title)}&ep=${encodeURIComponent(`${unitPrefix}.${String(ep.number).padStart(3, '0')}`)}&mid=${encodeURIComponent(mediaId)}&type=${type}`, + `&title=${encodeURIComponent(title)}&ep=${encodeURIComponent(`${unitPrefix}.${String(ep.number).padStart(3, '0')}`)}&mid=${encodeURIComponent(mediaId)}&type=${type}`; + navigate( + metaProvider && metaId + ? `${base}&meta=${metaProvider}&metaId=${encodeURIComponent(metaId)}` + : base, ); + }; + + const infoHref = + metaProvider && metaId ? `/media?meta=${metaProvider}&id=${encodeURIComponent(metaId)}` : null; - // Reset active source when stream changes useEffect(() => { setActiveIdx(0); }, [unitId, lang]); return (
+ {infoHref && ( +
+ + ← {title} + + {epLabel && / {epLabel}} +
+ )}
{isFetching && ( -
-

+

+

resolving {isManga ? 'pages' : 'stream'}...

)} {isError && ( -
+

{String(error)}

)} @@ -380,15 +371,13 @@ export default function Stream() { subtitles={subtitles} langUI={ availableLangs.length > 1 && ( -
- LANG +
+ LANG {availableLangs.map((l) => ( @@ -401,19 +390,17 @@ export default function Stream() { {data?.type === 'manga' && data.pages && ( <> -
+
{availableLangs.length > 1 && ( -
- LANG +
+ LANG {availableLangs.map((l) => ( @@ -424,29 +411,28 @@ export default function Stream() { )}
- {/* Episode navigation */} {episodes && ( -
+
@@ -454,22 +440,22 @@ export default function Stream() {
{showEpisodes && ( -
+
{episodes.map((ep) => { const isCurrent = ep.number === currentEpNum; return (
)} - {/* Source selector */} - {streams.length > 0 && ( -
-
- - SOURCES ({streams.length}) - - -
- {streams.map((s, i) => { - let displayUrl = s.sourceUrl; - try { - const u = new URL(s.sourceUrl); - if (u.pathname === '/proxy' && u.searchParams.has('url')) { - const targetUrl = new URL(u.searchParams.get('url')!); - displayUrl = targetUrl.hostname; - } else { - displayUrl = u.hostname; - } - } catch {} - - return ( -
- - e.stopPropagation()} - className="mt-0.5 shrink-0 text-xs text-[#333] transition-colors hover:text-[#888]" + + e.stopPropagation()} + className="text-base-350 hover:text-base-600 mt-0.5 shrink-0 text-xs transition-colors" + > + {s.isHLS ? '↗' : '↓'} + +
+ ); + })} + + ) : ( +
+
+ + SOURCES ({streams.length}) + + +
+ {streams.map((s, i) => { + let displayUrl = s.sourceUrl; + try { + const u = new URL(s.sourceUrl); + if (u.pathname === '/proxy' && u.searchParams.has('url')) { + const targetUrl = new URL(u.searchParams.get('url')!); + displayUrl = targetUrl.hostname; + } else { + displayUrl = u.hostname; + } + } catch {} + + return ( +
- {s.isHLS ? '↗' : '↓'} - -
- ); - })} -
- )} + + e.stopPropagation()} + className="text-base-350 hover:text-base-600 mt-0.5 shrink-0 text-xs transition-colors" + > + {s.isHLS ? '↗' : '↓'} + +
+ ); + })} +
+ ))}
); } diff --git a/tests/dom.test.ts b/tests/dom.test.ts index a51219a..01b0560 100644 --- a/tests/dom.test.ts +++ b/tests/dom.test.ts @@ -34,9 +34,11 @@ class MockParser implements IDomParser { describe('DOM Registry and Parsers', () => { it('auto-registers linkedom so BrowserDomParser works in Node without manual setup', () => { - // linkedom is now a direct dependency and dom.ts registers it automatically + // linkedom is now a direct dependency and dom.ts registers it automatically. + // Parse with a wrapper so the target element is a descendant (querySelector + // searches children, not the root itself). const parser = new BrowserDomParser(); - const root = parser.parse('
hello
'); + const root = parser.parse('
hello
'); expect(root.querySelector('#test')?.textContent).toBe('hello'); }); From 6892076e82ff8acf7daded9a76a03412ff2c7bf8 Mon Sep 17 00:00:00 2001 From: HEXXT Date: Thu, 18 Jun 2026 15:59:53 +0100 Subject: [PATCH 02/19] =?UTF-8?q?feat(scaffold):=20Phase=200=20=E2=80=94?= =?UTF-8?q?=20internal=20layout=20+=20empty=202.0=20scaffolding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move transport/ plumbing into src/internal/ (http, dom, hls, rateLimiter, retry, transport), urn helpers to src/internal/id.ts, and MappingClient to src/internal/mapping.ts. Old locations replaced with re-export stubs so existing tests and imports continue to work unchanged. Add empty scaffold files: sdk.ts, types.ts, errors.ts, config.ts, registry.ts, progressive.ts, health.ts — placeholders for Phases 2–7. Full unit test suite (94 tests) passes. tsc --noEmit clean. --- src/config.ts | 1 + src/errors.ts | 1 + src/health.ts | 1 + src/internal/dom.ts | 69 +++++ src/internal/hls.ts | 70 +++++ src/internal/http.ts | 261 ++++++++++++++++ src/internal/id.ts | 129 ++++++++ src/internal/mapping.ts | 563 ++++++++++++++++++++++++++++++++++ src/internal/rateLimiter.ts | 186 ++++++++++++ src/internal/retry.ts | 164 ++++++++++ src/internal/transport.ts | 196 ++++++++++++ src/meta/MappingClient.ts | 564 +---------------------------------- src/progressive.ts | 1 + src/registry.ts | 1 + src/sdk.ts | 1 + src/transport/dom.ts | 70 +---- src/transport/hlsUtils.ts | 71 +---- src/transport/http.ts | 262 +--------------- src/transport/rateLimiter.ts | 187 +----------- src/transport/retry.ts | 165 +--------- src/transport/transport.ts | 197 +----------- src/types.ts | 1 + src/utils/urn.ts | 130 +------- 23 files changed, 1653 insertions(+), 1638 deletions(-) create mode 100644 src/config.ts create mode 100644 src/errors.ts create mode 100644 src/health.ts create mode 100644 src/internal/dom.ts create mode 100644 src/internal/hls.ts create mode 100644 src/internal/http.ts create mode 100644 src/internal/id.ts create mode 100644 src/internal/mapping.ts create mode 100644 src/internal/rateLimiter.ts create mode 100644 src/internal/retry.ts create mode 100644 src/internal/transport.ts create mode 100644 src/progressive.ts create mode 100644 src/registry.ts create mode 100644 src/sdk.ts create mode 100644 src/types.ts diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..6e9ee55 --- /dev/null +++ b/src/config.ts @@ -0,0 +1 @@ +// Phase 2: SdkOptions + resolveOptions() diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..691623e --- /dev/null +++ b/src/errors.ts @@ -0,0 +1 @@ +// Phase 2: AniError + AniErrorCode diff --git a/src/health.ts b/src/health.ts new file mode 100644 index 0000000..5a6a933 --- /dev/null +++ b/src/health.ts @@ -0,0 +1 @@ +// Phase 4: Rolling success/latency tracker per source diff --git a/src/internal/dom.ts b/src/internal/dom.ts new file mode 100644 index 0000000..eea464b --- /dev/null +++ b/src/internal/dom.ts @@ -0,0 +1,69 @@ +import { DOMParser as LinkedomParser } from 'linkedom'; +import { IDomElement, IDomParser } from '../types/index.js'; + +// Auto-register linkedom in environments without a native DOMParser (Node, Bun). +// Skipped if a native DOMParser is present (browsers) or a custom one was already +// set, so DomRegistry.register() still takes full precedence. +if (typeof globalThis.DOMParser === 'undefined') { + (globalThis as any).DOMParser = LinkedomParser; +} + +export class BrowserDomElement implements IDomElement { + constructor(private element: Element) {} + + public querySelector(selector: string): IDomElement | null { + const el = this.element.querySelector(selector); + return el ? new BrowserDomElement(el) : null; + } + + public querySelectorAll(selector: string): IDomElement[] { + const els = this.element.querySelectorAll(selector); + return Array.from(els).map((el) => new BrowserDomElement(el)); + } + + public getAttribute(name: string): string | null { + return this.element.getAttribute(name); + } + + public get textContent(): string | null { + return this.element.textContent; + } + + public get outerHTML(): string { + return this.element.outerHTML; + } + + public get innerHTML(): string { + return this.element.innerHTML; + } +} + +export class BrowserDomParser implements IDomParser { + public parse(html: string): IDomElement { + if (typeof globalThis.DOMParser === 'undefined') { + throw new Error( + 'DOMParser is not available in this environment. Please register a custom DOM Parser via DomRegistry.register().', + ); + } + const parser = new globalThis.DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + // Ensure we start from documentElement or body if needed + return new BrowserDomElement(doc.documentElement || doc.body); + } +} + +export class DomRegistry { + private static parser: IDomParser = new BrowserDomParser(); + + public static register(customParser: IDomParser): void { + this.parser = customParser; + } + + public static getParser(): IDomParser { + return this.parser; + } + + public static parse(html: string): IDomElement { + return this.parser.parse(html); + } +} diff --git a/src/internal/hls.ts b/src/internal/hls.ts new file mode 100644 index 0000000..8ba409d --- /dev/null +++ b/src/internal/hls.ts @@ -0,0 +1,70 @@ +import { HttpClient } from './http.js'; + +export class HlsUtils { + /** + * Rewrites chunk and sub-playlist URLs in an M3U8 playlist to route through the proxy. + * @param manifestText Raw M3U8 content + * @param playlistUrl Original URL of the M3U8 file (used to resolve relative paths) + * @param httpClient HttpClient instance to obtain proxy configuration + */ + public static rewriteManifest( + manifestText: string, + playlistUrl: string, + httpClient: HttpClient, + ): string { + if (!httpClient.getProxyUrl()) { + return manifestText; + } + + const lines = manifestText.split(/\r?\n/); + const rewrittenLines = lines.map((line) => { + const trimmed = line.trim(); + // Parse URI="..." in tags like #EXT-X-KEY or #EXT-X-MAP + if (trimmed.startsWith('#')) { + return this.rewriteTagsWithUris(trimmed, playlistUrl, httpClient); + } + if (trimmed.length === 0) { + return line; + } + // This is a URI line (either a chunk or a sub-playlist) + const absoluteUrl = this.resolveUrl(playlistUrl, trimmed); + return httpClient.requestUrl(absoluteUrl); + }); + + return rewrittenLines.join('\n'); + } + + /** + * Resolves relative URLs against a base URL + */ + private static resolveUrl(base: string, relative: string): string { + try { + return new URL(relative, base).href; + } catch { + if (relative.startsWith('http://') || relative.startsWith('https://')) { + return relative; + } + const lastSlash = base.lastIndexOf('/'); + if (lastSlash === -1) return relative; + const basePath = base.substring(0, lastSlash + 1); + return `${basePath}${relative}`; + } + } + + /** + * Helper to rewrite inline URIs in tags like #EXT-X-KEY:METHOD=AES-128,URI="key.key" + */ + private static rewriteTagsWithUris( + line: string, + playlistUrl: string, + httpClient: HttpClient, + ): string { + // Matches URI="value" or URI='value' + const uriRegex = /URI=(["'])(.*?)\1/g; + return line.replace(uriRegex, (match, quote, uri) => { + const absoluteUrl = this.resolveUrl(playlistUrl, uri); + const proxiedUrl = httpClient.requestUrl(absoluteUrl); + return `URI=${quote}${proxiedUrl}${quote}`; + }); + } +} diff --git a/src/internal/http.ts b/src/internal/http.ts new file mode 100644 index 0000000..7866fed --- /dev/null +++ b/src/internal/http.ts @@ -0,0 +1,261 @@ +import { + DEFAULT_RATE_LIMITS, + PerHostRateLimits, + RateLimitConfig, + RateLimiter, +} from './rateLimiter.js'; +import { + DEFAULT_RETRY_STATUSES, + HttpRetryableError, + RetryConfig, + parseRetryAfter, + withRetry, +} from './retry.js'; +import { CurlFallbackTransport, HttpTransport } from './transport.js'; + +export interface HttpClientConfig { + proxyUrl?: string; + proxyType?: 'prepend' | 'query'; + proxyQueryParam?: string; + defaultHeaders?: Record; + timeoutMs?: number; + /** + * Per-host token-bucket rate limits. Merged on top of + * {@link DEFAULT_RATE_LIMITS}, which covers AniList / Jikan / Kitsu / + * MALSync / Anify / arm-server with their published quotas. Pass an + * empty object to start blank. + */ + rateLimits?: PerHostRateLimits; + /** Optional default policy for hosts not in the per-host map. */ + defaultRateLimit?: RateLimitConfig; + /** Disable rate limiting entirely (e.g. in tests). */ + disableRateLimit?: boolean; + /** + * Retry policy. Defaults to 3 attempts with exponential backoff (250ms + * base) on 408/425/429/5xx and transient network errors. Honours + * `Retry-After`. + */ + retry?: RetryConfig | false; + /** + * Pluggable transport. Defaults to {@link CurlFallbackTransport} on + * Node (fetch + curl fallback) and degrades to a plain `fetch` on + * runtimes that don't expose `child_process`. Pass a {@link FetchTransport} + * to disable the curl fallback explicitly, or any custom + * {@link HttpTransport} for full control (e.g. an Undici dispatcher, + * a Cloudflare-bypass proxy, or an in-process test transport). + */ + transport?: HttpTransport; +} + +export class HttpClient { + private proxyUrl?: string; + private proxyType: 'prepend' | 'query'; + private proxyQueryParam: string; + private defaultHeaders: Record; + private timeoutMs: number; + private rateLimiter?: RateLimiter; + private retryConfig: RetryConfig | false; + private transport: HttpTransport; + + constructor(config: HttpClientConfig = {}) { + this.proxyUrl = config.proxyUrl; + this.proxyType = config.proxyType || 'prepend'; + this.proxyQueryParam = config.proxyQueryParam || 'url'; + this.defaultHeaders = config.defaultHeaders || {}; + this.timeoutMs = config.timeoutMs || 10000; + if (!config.disableRateLimit) { + this.rateLimiter = new RateLimiter( + { ...DEFAULT_RATE_LIMITS, ...(config.rateLimits ?? {}) }, + config.defaultRateLimit, + ); + } + this.retryConfig = config.retry === false ? false : (config.retry ?? {}); + this.transport = config.transport ?? new CurlFallbackTransport({ timeoutMs: this.timeoutMs }); + } + + /** Live rate-limiter; useful for tests and observability. May be undefined when disabled. */ + public getRateLimiter(): RateLimiter | undefined { + return this.rateLimiter; + } + + public getTransport(): HttpTransport { + return this.transport; + } + + public getProxyUrl(): string | undefined { + return this.proxyUrl; + } + + public getProxyType(): 'prepend' | 'query' { + return this.proxyType; + } + + public getProxyQueryParam(): string { + return this.proxyQueryParam; + } + + public getDefaultHeaders(): Record { + return this.defaultHeaders; + } + + public requestUrl(url: string): string { + if (!this.proxyUrl) return url; + if (this.proxyType === 'prepend') { + const base = this.proxyUrl.endsWith('/') ? this.proxyUrl : `${this.proxyUrl}/`; + // Strip target protocol if the prepend proxy expects path prepending + // e.g. proxy.com/target.com/path + const target = url.replace(/^(https?:\/\/)/, ''); + return `${base}${target}`; + } else { + const separator = this.proxyUrl.includes('?') ? '&' : '?'; + return `${this.proxyUrl}${separator}${this.proxyQueryParam}=${encodeURIComponent(url)}`; + } + } + + public async request(url: string, options: RequestInit = {}): Promise { + const signal = options.signal as AbortSignal | null | undefined; + const host = safeHostname(this.requestUrl(url)); + + if (this.retryConfig === false) { + if (this.rateLimiter && host) await this.rateLimiter.acquire(host, signal ?? undefined); + return this.requestOnce(url, options); + } + return withRetry( + async () => { + // Re-acquire on every attempt — each fetch is a billable upstream + // call and must respect the per-host budget independently. Doing + // this outside the retry loop would let a noisy retry-storm + // silently blow past the configured rate limit. + if (this.rateLimiter && host) await this.rateLimiter.acquire(host, signal ?? undefined); + const res = await this.requestOnce(url, options); + const retryStatuses = + this.retryConfig === false + ? DEFAULT_RETRY_STATUSES + : (this.retryConfig.retryStatuses ?? DEFAULT_RETRY_STATUSES); + if (retryStatuses.includes(res.status)) { + const ra = parseRetryAfter(res.headers.get('retry-after')); + throw new HttpRetryableError(res.status, ra); + } + return res; + }, + this.retryConfig, + signal ?? undefined, + ); + } + + private async requestOnce(url: string, options: RequestInit = {}): Promise { + const targetUrl = this.requestUrl(url); + const headers: Record = { ...this.defaultHeaders }; + if (options.headers) { + if (options.headers instanceof Headers) { + options.headers.forEach((value, key) => { + headers[key] = value; + }); + } else if (Array.isArray(options.headers)) { + for (const [key, value] of options.headers) { + headers[key] = value; + } + } else { + Object.assign(headers, options.headers); + } + } + + // Compose the timeout signal with the caller's signal (if any) so a + // caller-supplied AbortSignal still cancels the in-flight request. + const callerSignal = options.signal as AbortSignal | null | undefined; + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(new Error('Request timed out')), this.timeoutMs); + let onCallerAbort: (() => void) | undefined; + if (callerSignal) { + if (callerSignal.aborted) { + clearTimeout(id); + throw abortReason(callerSignal); + } + onCallerAbort = () => controller.abort(callerSignal.reason); + callerSignal.addEventListener('abort', onCallerAbort, { once: true }); + } + const cleanup = () => { + clearTimeout(id); + if (onCallerAbort) callerSignal!.removeEventListener('abort', onCallerAbort); + }; + + try { + const res = await this.transport.fetch(targetUrl, { + ...options, + headers, + signal: controller.signal, + }); + cleanup(); + return res; + } catch (err) { + cleanup(); + throw err; + } + } + + public async get(url: string, options: RequestInit = {}): Promise { + return this.request(url, { ...options, method: 'GET' }); + } + + public async post(url: string, body?: any, options: RequestInit = {}): Promise { + const headers: Record = {}; + if (options.headers) { + if (options.headers instanceof Headers) { + options.headers.forEach((value, key) => { + headers[key] = value; + }); + } else if (Array.isArray(options.headers)) { + for (const [key, value] of options.headers) { + headers[key] = value; + } + } else { + Object.assign(headers, options.headers); + } + } + let finalBody = body; + if ( + body && + typeof body === 'object' && + !(body instanceof FormData) && + !(body instanceof URLSearchParams) + ) { + if (!headers['Content-Type']) { + headers['Content-Type'] = 'application/json'; + } + finalBody = JSON.stringify(body); + } + return this.request(url, { ...options, method: 'POST', headers, body: finalBody }); + } + + public setCookie(name: string, value: string): void { + const existingCookie = this.defaultHeaders['Cookie'] || ''; + const cookies = existingCookie ? existingCookie.split(';').map((c) => c.trim()) : []; + const newCookies = cookies.filter((c) => !c.startsWith(`${name}=`)); + newCookies.push(`${name}=${value}`); + this.defaultHeaders['Cookie'] = newCookies.join('; '); + } + + public setUserAgent(userAgent: string): void { + this.defaultHeaders['User-Agent'] = userAgent; + } +} + +/** + * Extract the hostname from a URL for rate-limiter bucketing. Returns + * `undefined` if the URL is relative or unparseable — those calls aren't + * rate-limited. + */ +function safeHostname(url: string): string | undefined { + try { + return new URL(url).hostname; + } catch { + return undefined; + } +} + +function abortReason(signal: AbortSignal): Error { + if (signal.reason instanceof Error) return signal.reason; + const e = new Error(typeof signal.reason === 'string' ? signal.reason : 'Aborted'); + e.name = 'AbortError'; + return e; +} diff --git a/src/internal/id.ts b/src/internal/id.ts new file mode 100644 index 0000000..c3e026c --- /dev/null +++ b/src/internal/id.ts @@ -0,0 +1,129 @@ +/** + * Unified Resource Name (URN) helpers. + * + * Every ID emitted by an SDK provider is prefixed with the provider's `id` + * followed by a single colon. The first colon is the separator — the raw ID + * that follows is treated as opaque and may itself contain colons, slashes, + * or other characters. + * + * Examples: + * `allmanga:5jzpRTJWnubrgHm5G` (media URN) + * `allmanga:5jzpRTJWnubrgHm5G/1` (content unit URN) + * `animeparadise:abc:xyz` (raw ID itself contains a colon) + * `anilist:21` (meta provider URN) + * + * Unifying ID space means callers can route any URN to the right provider + * without out-of-band knowledge of which provider it came from. + */ + +import type { Urn } from '../types/index.js'; + +/** True when the string looks like `providerId:rawId` for the given provider. */ +export function isUrn(value: string, providerId?: string): boolean { + const sep = value.indexOf(':'); + if (sep <= 0) return false; + if (providerId == null) return true; + return value.slice(0, sep) === providerId; +} + +/** Build a URN. The raw ID is taken as-is — no escaping is applied. */ +export function buildUrn(providerId: string, rawId: string): Urn { + if (!providerId) throw new Error('buildUrn: providerId is required'); + if (rawId == null) throw new Error('buildUrn: rawId is required'); + return `${providerId}:${rawId}`; +} + +/** + * Parse a URN into its provider and raw-ID parts. If the input has no colon, + * `providerId` is the empty string and `rawId` is the original input — this + * lets callers be liberal about accepting legacy bare IDs. + */ +export function parseUrn(urn: string): { providerId: string; rawId: string } { + const sep = urn.indexOf(':'); + if (sep < 0) return { providerId: '', rawId: urn }; + return { providerId: urn.slice(0, sep), rawId: urn.slice(sep + 1) }; +} + +/** + * Strip the URN prefix when it matches `providerId`. If the input has no + * prefix or a different prefix, it is returned unchanged — this is what lets + * providers accept both URN and legacy bare IDs. + */ +export function unwrapUrn(providerId: string, urn: string): string { + const sep = urn.indexOf(':'); + if (sep < 0) return urn; + const prefix = urn.slice(0, sep); + if (prefix !== providerId) return urn; + return urn.slice(sep + 1); +} + +/** + * Strict version of {@link unwrapUrn} — throws if the URN doesn't belong + * to `providerId`. Use this when routing decisions depend on the prefix + * being correct (e.g. before dispatching a `meta:anilist:21` to a content + * provider that wouldn't know what to do with it). + */ +export function strictUnwrapUrn(providerId: string, urn: string): string { + const sep = urn.indexOf(':'); + if (sep < 0) { + throw new Error(`strictUnwrapUrn: bare ID "${urn}" rejected (expected "${providerId}:…")`); + } + const prefix = urn.slice(0, sep); + if (prefix !== providerId) { + throw new Error( + `strictUnwrapUrn: prefix "${prefix}" does not match "${providerId}" for URN "${urn}"`, + ); + } + return urn.slice(sep + 1); +} + +/** + * Typed catalogue URN helpers. + * + * MAL and Kitsu IDs aren't globally unique — a single integer can belong to + * either an anime or a manga. We encode the catalogue type as the second + * segment so the URN is unambiguous: + * + * `mal:anime:21` `mal:manga:13` `kitsu:anime:11013` + * + * This lets routing logic (and the meta-provider's `fetchMediaInfo`) pick + * the right endpoint without falling back on a "try anime first, 404, try + * manga" heuristic. + * + * The first colon still separates `providerId`; the *second* colon is + * conventional only when the provider opts in. AniList (single ID + * namespace) doesn't use it. + */ +export type CatalogueKind = 'anime' | 'manga'; + +export function buildTypedUrn( + providerId: string, + kind: CatalogueKind, + rawId: string | number, +): Urn { + return `${providerId}:${kind}:${rawId}`; +} + +/** + * Parse a typed URN. Returns `{kind, rawId}` when the second segment is + * `"anime"` or `"manga"`, otherwise treats the whole post-prefix string as + * a bare raw ID with `kind: undefined`. Callers can fall through to the + * untyped path when the kind is missing. + */ +export function parseTypedUrn( + providerId: string, + urn: string, +): { kind?: CatalogueKind; rawId: string } { + const sep = urn.indexOf(':'); + if (sep < 0) return { rawId: urn }; + const prefix = urn.slice(0, sep); + const rest = urn.slice(sep + 1); + if (prefix !== providerId) return { rawId: urn }; + const sep2 = rest.indexOf(':'); + if (sep2 < 0) return { rawId: rest }; + const candidate = rest.slice(0, sep2); + if (candidate === 'anime' || candidate === 'manga') { + return { kind: candidate, rawId: rest.slice(sep2 + 1) }; + } + return { rawId: rest }; +} diff --git a/src/internal/mapping.ts b/src/internal/mapping.ts new file mode 100644 index 0000000..1938e7d --- /dev/null +++ b/src/internal/mapping.ts @@ -0,0 +1,563 @@ +import { HttpClient } from './http.js'; +import { BaseProvider, CallOptions } from '../providers/BaseProvider.js'; +import { + IMediaMappings, + IMediaMetadata, + IMediaSearchResult, + IMediaTitle, + SdkCache, +} from '../types/index.js'; +import { unwrapUrn } from './id.js'; +import { bestSimilarity, normalizeTitle } from '../meta/similarity.js'; + +/** + * Cross-source ID resolver. + * + * Given an `IMediaMetadata` record (`anilist:21`) and a `BaseProvider` + * (`allmangaProvider`), returns the raw media ID that provider uses for + * the same title — applying as much rigour as the data allows: + * + * 1. **Provider-native lookup** (`provider.lookupByMapping`) — when a + * site indexes its catalogue by AniList/MAL ID directly, that's + * authoritative and cheapest. Hook is optional per provider. + * 2. **External mapping APIs** raced in parallel: MALSync, Anify, and + * arm-server. First non-empty result wins; the others get cached for + * later providers that need them. Provider names are taken from + * `Provider.malsyncSites` / fallback aliases. + * 3. **Fuzzy title search** as a final fallback. Searches the provider + * with multiple title variants in parallel, then ranks every + * candidate by a composite similarity metric, with `year` and + * `catalogType` discriminators applied as hard filters. When the top + * candidate is borderline (similarity within `verifyBand` of the + * threshold), we issue one extra `fetchContentUnits` round-trip to + * cross-check `episodeCount` before accepting. + * + * Results are persisted to an `SdkCache` keyed by + * `mapping:${metaProvider}:${metaNativeId}:${contentProvider}`. The + * metadata object is **never** mutated — that surprised callers and made + * `SdkCache`-cached metadata records dangerous to share across calls. + */ +export interface MappingClientOptions { + /** Optional read/write cache for resolved mappings (and external API responses). */ + cache?: SdkCache; + /** Disable MALSync lookups. */ + disableMalsync?: boolean; + /** Disable Anify lookups. */ + disableAnify?: boolean; + /** Disable arm-server lookups (only useful for anime). */ + disableArmServer?: boolean; + /** Minimum composite similarity to accept a fuzzy match. 0–1; default 0.78. */ + minSimilarity?: number; + /** + * Width of the "borderline" band below the threshold within which we + * trigger the episode-count cross-check. Default 0.07 — i.e. with + * `minSimilarity: 0.78`, matches between 0.71 and 0.85 get verified. + */ + verifyBand?: number; + /** Tolerance for the year discriminator. Default 2. */ + yearTolerance?: number; + /** Tolerance for the episode-count discriminator. Default 3. */ + episodeCountTolerance?: number; + /** Max search candidates per provider call. Default 12. */ + fuzzyCandidateLimit?: number; + /** Max parallel `provider.search` calls during fuzzy match. Default 4. */ + fuzzyConcurrency?: number; +} + +export type MappingMethod = 'cached' | 'provider' | 'malsync' | 'anify' | 'arm' | 'fuzzy'; + +export interface MappingResolution { + providerId: string; + rawMediaId: string; + matchedTitle: string; + method: MappingMethod; + similarity?: number; +} + +/** + * Provider-id aliases for external mapping services. Used when a provider + * doesn't declare its own `malsyncSites` / `anifySites` (which it should + * — these constants are the fallback safety net for the SDK's own + * built-in providers). + */ +const BUILT_IN_MALSYNC_ALIASES: Record = { + mangadex: ['Mangadex', 'MangaDex'], + mangapill: ['Mangapill'], + weebcentral: ['Weebcentral', 'WeebCentral'], +}; + +interface MalsyncResponse { + malId?: number; + anilistId?: number; + Sites?: Record< + string, + Record + >; +} + +interface AnifyMappingEntry { + id: string; + providerId: string; + providerType?: 'ANIME' | 'MANGA'; +} + +interface AnifyResponse { + mappings?: AnifyMappingEntry[]; + episodeCount?: number; +} + +interface ArmServerResponse { + anilist?: number; + mal?: number; + kitsu?: number; + anidb?: number; + notify?: string; + livechart?: number; +} + +export class MappingClient { + constructor( + private http: HttpClient, + private options: MappingClientOptions = {}, + ) {} + + /** + * Resolve `metadata` → raw media ID on `contentProvider`. + * + * Returns `null` when no resolution method finds a confident match. The + * input `metadata` object is **never mutated**. + */ + public async resolveProviderMediaId( + metadata: IMediaMetadata, + contentProvider: BaseProvider, + options: CallOptions = {}, + ): Promise { + const cacheKey = mappingCacheKey(metadata, contentProvider); + // ── 0. SdkCache ─────────────────────────────────────────────────────── + if (this.options.cache) { + const hit = await this.options.cache.get(cacheKey); + if (hit !== undefined && hit !== null) { + const stored = hit as MappingResolution; + return { ...stored, method: 'cached' }; + } + } + + // ── 1. Provider-native lookup ───────────────────────────────────────── + if (contentProvider.lookupByMapping && metadata.mappings) { + try { + const raw = await contentProvider.lookupByMapping(metadata.mappings, options); + if (raw) + return this.acceptAndCache( + cacheKey, + contentProvider, + raw, + displayTitle(metadata), + 'provider', + ); + } catch { + // Fall through — provider-side lookup is best-effort. + } + } + + // ── 2. External mapping APIs (raced) ────────────────────────────────── + const ext = await this.resolveFromExternalMappings(metadata, contentProvider, options); + if (ext) { + return this.acceptAndCache( + cacheKey, + contentProvider, + ext.rawId, + displayTitle(metadata), + ext.method, + ); + } + + // ── 3. Fuzzy search ─────────────────────────────────────────────────── + const fuzzy = await this.fuzzyMatch(metadata, contentProvider, options); + if (fuzzy) { + const cached = await this.acceptAndCache( + cacheKey, + contentProvider, + fuzzy.rawMediaId, + fuzzy.matchedTitle, + 'fuzzy', + ); + return { ...cached, similarity: fuzzy.similarity }; + } + + return null; + } + + // ── External APIs ───────────────────────────────────────────────────────── + + /** + * Race MALSync, Anify, and arm-server. The first one to return a + * matching alias for `contentProvider` wins; the others continue in the + * background and their answers are cached on the SdkCache so a *future* + * lookup for a different content provider can pick them up cheaply. + */ + private async resolveFromExternalMappings( + metadata: IMediaMetadata, + contentProvider: BaseProvider, + options: CallOptions, + ): Promise<{ rawId: string; method: MappingMethod } | null> { + const type: 'anime' | 'manga' = metadata.catalogType === 'MANGA' ? 'manga' : 'anime'; + const malsyncAliases = providerMalsyncAliases(contentProvider); + + const tasks: Array<{ method: MappingMethod; promise: Promise }> = []; + + if (!this.options.disableMalsync && (metadata.mappings?.anilist || metadata.mappings?.mal)) { + tasks.push({ + method: 'malsync', + promise: this.lookupMalsync(metadata, type, malsyncAliases, contentProvider.id, options), + }); + } + if (!this.options.disableAnify && (metadata.mappings?.anilist || metadata.mappings?.mal)) { + tasks.push({ + method: 'anify', + promise: this.lookupAnify(metadata, contentProvider.id, options), + }); + } + if (!this.options.disableArmServer && type === 'anime' && metadata.mappings?.anilist) { + tasks.push({ + method: 'arm', + promise: this.enrichMappingsViaArm(metadata, options).then(() => null), + // arm doesn't return provider-specific IDs — it gives us cross-source + // catalogue IDs (kitsu, anidb, notify, …) that we cache for later + // mapping-API calls. We never accept its result directly here. + }); + } + + if (tasks.length === 0) return null; + + // Race: resolve as soon as any task returns a non-null value. + // We can't use Promise.any (rejects ≠ no-match), so do it manually. + return new Promise<{ rawId: string; method: MappingMethod } | null>((resolve) => { + let remaining = tasks.length; + for (const { method, promise } of tasks) { + promise + .then((rawId) => { + if (rawId) resolve({ rawId, method }); + }) + .catch(() => {}) + .finally(() => { + remaining -= 1; + if (remaining === 0) resolve(null); + }); + } + }); + } + + private async lookupMalsync( + metadata: IMediaMetadata, + type: 'anime' | 'manga', + aliases: string[], + contentProviderId: string, + options: CallOptions, + ): Promise { + const anilistId = metadata.mappings?.anilist; + const malId = metadata.mappings?.mal; + const namespace = anilistId ? 'anilist' : 'mal'; + const id = anilistId ?? malId; + if (!id) return null; + const url = `https://api.malsync.moe/${namespace}/${type}/${id}`; + try { + const res = await this.http.get(url, { + headers: { Accept: 'application/json' }, + signal: options.signal, + }); + if (res.status !== 200) return null; + const data = (await res.json()) as MalsyncResponse; + const sites = data?.Sites; + if (!sites) return null; + for (const alias of aliases) { + const bucket = sites[alias]; + if (!bucket) continue; + const first = Object.values(bucket)[0]; + const raw = String(first?.identifier ?? '').trim(); + if (raw) return raw; + } + // Also stash any other provider hits we found in the cache so + // future lookups for those providers can short-circuit. + await this.stashSiblingHits(metadata, sites); + return null; + } catch { + return null; + } + } + + private async lookupAnify( + metadata: IMediaMetadata, + contentProviderId: string, + options: CallOptions, + ): Promise { + const anilistId = metadata.mappings?.anilist; + if (!anilistId) return null; + const url = `https://api.anify.tv/info/${anilistId}`; + try { + const res = await this.http.get(url, { + headers: { Accept: 'application/json' }, + signal: options.signal, + }); + if (res.status !== 200) return null; + const data = (await res.json()) as AnifyResponse; + const m = data?.mappings ?? []; + const hit = m.find((x) => x.providerId === contentProviderId); + return hit?.id ?? null; + } catch { + return null; + } + } + + /** + * arm-server doesn't speak content-provider IDs — it speaks catalogue + * IDs (kitsu, anidb, notify, livechart). We call it not to *resolve* + * but to *enrich* the in-memory mappings so subsequent MALSync/Anify + * lookups have more keys to try. Cached on the SdkCache. + */ + private async enrichMappingsViaArm( + metadata: IMediaMetadata, + options: CallOptions, + ): Promise { + const anilistId = metadata.mappings?.anilist; + if (!anilistId) return null; + const cacheKey = `arm:anilist:${anilistId}`; + if (this.options.cache) { + const hit = await this.options.cache.get(cacheKey); + if (hit !== undefined && hit !== null) return hit as IMediaMappings; + } + try { + const res = await this.http.get( + `https://arm.haglund.dev/api/v2/ids?source=anilist&id=${anilistId}`, + { headers: { Accept: 'application/json' }, signal: options.signal }, + ); + if (res.status !== 200) return null; + const data = (await res.json()) as ArmServerResponse; + const enriched: IMediaMappings = { + anilist: data.anilist, + mal: data.mal, + kitsu: data.kitsu, + anidb: data.anidb, + }; + if (this.options.cache) await this.options.cache.set(cacheKey, enriched); + return enriched; + } catch { + return null; + } + } + + /** Best-effort cache-warming: stash MALSync's other site hits in SdkCache. */ + private async stashSiblingHits( + metadata: IMediaMetadata, + sites: NonNullable, + ): Promise { + if (!this.options.cache) return; + // We can't know which BaseProvider.id a site corresponds to without + // querying the registered providers — but the matched site name is a + // stable key, so cache by `malsync:${siteName}:${anilistOrMal}:${id}` + // and let future MappingClient instances pick up. + const anilistId = metadata.mappings?.anilist; + const malId = metadata.mappings?.mal; + for (const [siteName, bucket] of Object.entries(sites)) { + const first = Object.values(bucket)[0]; + const raw = String(first?.identifier ?? '').trim(); + if (!raw) continue; + if (anilistId) await this.options.cache.set(`malsync:${siteName}:anilist:${anilistId}`, raw); + if (malId) await this.options.cache.set(`malsync:${siteName}:mal:${malId}`, raw); + } + } + + // ── Fuzzy matching ──────────────────────────────────────────────────────── + + private async fuzzyMatch( + metadata: IMediaMetadata, + contentProvider: BaseProvider, + options: CallOptions, + ): Promise { + const threshold = this.options.minSimilarity ?? 0.78; + const verifyBand = this.options.verifyBand ?? 0.07; + const limit = this.options.fuzzyCandidateLimit ?? 12; + const concurrency = this.options.fuzzyConcurrency ?? 4; + const yearTol = this.options.yearTolerance ?? 2; + + const queries = uniqueQueries([ + metadata.title.userPreferred, + metadata.title.english, + metadata.title.romaji, + metadata.title.native, + ...(metadata.synonyms ?? []), + ]); + if (queries.length === 0) return null; + + const candidates = await runParallelSearches( + contentProvider, + queries.slice(0, concurrency), + limit, + options, + ); + if (candidates.length === 0) return null; + + const altTitles = [ + metadata.title.userPreferred, + metadata.title.english, + metadata.title.romaji, + metadata.title.native, + ...(metadata.synonyms ?? []), + ]; + + // Score every candidate with all known discriminators applied. + type Scored = { + result: IMediaSearchResult; + score: number; + catalogMatch: boolean; + yearMatch: boolean; + }; + const scored: Scored[] = candidates.map((c) => ({ + result: c, + score: bestSimilarity(c.title, altTitles), + catalogMatch: c.catalogType === metadata.catalogType, + yearMatch: yearIsCompatible(metadata.year, getCandidateYear(c), yearTol), + })); + + // Hard filters first. + const filtered = scored.filter((s) => s.catalogMatch && s.yearMatch); + const pool = filtered.length > 0 ? filtered : scored; + + pool.sort((a, b) => b.score - a.score); + const top = pool[0]; + if (!top || top.score < threshold - verifyBand) return null; + + const raw = unwrapUrn(contentProvider.id, top.result.id); + + // High-confidence: above threshold and catalogType matches → accept directly. + if (top.score >= threshold && top.catalogMatch && top.yearMatch) { + return makeRes(contentProvider, raw, top); + } + + // Borderline → cross-check episode count. + if (typeof metadata.episodeCount === 'number') { + try { + const units = await contentProvider.fetchContentUnits(top.result.id, options); + const actualCount = units.length; + const expected = metadata.episodeCount; + const tol = this.options.episodeCountTolerance ?? 3; + if (Math.abs(actualCount - expected) <= tol) { + return makeRes(contentProvider, raw, top); + } + } catch { + // Provider blew up — fall back to similarity threshold alone. + } + } + + // No cross-check possible; accept only if clearly above threshold. + if (top.score >= threshold) return makeRes(contentProvider, raw, top); + return null; + } + + // ── Acceptance / persistence helpers ────────────────────────────────────── + + private async acceptAndCache( + cacheKey: string, + contentProvider: BaseProvider, + rawMediaId: string, + matchedTitle: string, + method: MappingMethod, + ): Promise { + const resolution: MappingResolution = { + providerId: contentProvider.id, + rawMediaId, + matchedTitle, + method, + }; + if (this.options.cache) await this.options.cache.set(cacheKey, resolution); + return resolution; + } +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function mappingCacheKey(metadata: IMediaMetadata, contentProvider: BaseProvider): string { + return `mapping:${metadata.providerId}:${unwrapUrn(metadata.providerId, metadata.id)}:${contentProvider.id}`; +} + +function displayTitle(m: IMediaMetadata): string { + return m.title.userPreferred ?? m.title.english ?? m.title.romaji ?? ''; +} + +function uniqueQueries(raw: Array): string[] { + const out: string[] = []; + const seen = new Set(); + for (const q of raw) { + if (!q) continue; + const norm = normalizeTitle(q); + if (!norm || seen.has(norm)) continue; + seen.add(norm); + out.push(q); + } + return out; +} + +/** + * Read the provider's MALSync aliases — first from a `malsyncSites` static + * property on the constructor, then from `BUILT_IN_MALSYNC_ALIASES`. + * Returns at least one entry (the provider's own id, lowercased and + * capitalized) so single-word provider IDs work without configuration. + */ +function providerMalsyncAliases(provider: BaseProvider): string[] { + const ctor = provider.constructor as unknown as { malsyncSites?: readonly string[] }; + if (ctor.malsyncSites && ctor.malsyncSites.length > 0) return [...ctor.malsyncSites]; + const builtIn = BUILT_IN_MALSYNC_ALIASES[provider.id]; + if (builtIn) return builtIn; + return [capitalize(provider.id)]; +} + +function capitalize(s: string): string { + if (!s) return s; + return s[0].toUpperCase() + s.slice(1); +} + +function yearIsCompatible( + expected: number | undefined, + actual: number | undefined, + tolerance: number, +): boolean { + if (expected == null || actual == null) return true; // unknown → not a hard filter + return Math.abs(expected - actual) <= tolerance; +} + +function getCandidateYear(c: IMediaSearchResult): number | undefined { + return c.year; +} + +function makeRes( + contentProvider: BaseProvider, + raw: string, + s: { result: IMediaSearchResult; score: number }, +): MappingResolution { + return { + providerId: contentProvider.id, + rawMediaId: raw, + matchedTitle: s.result.title, + method: 'fuzzy', + similarity: s.score, + }; +} + +async function runParallelSearches( + contentProvider: BaseProvider, + queries: string[], + perQueryLimit: number, + options: CallOptions, +): Promise { + const results = await Promise.allSettled(queries.map((q) => contentProvider.search(q, options))); + const seen = new Set(); + const out: IMediaSearchResult[] = []; + for (const r of results) { + if (r.status !== 'fulfilled') continue; + for (const hit of r.value.slice(0, perQueryLimit)) { + if (seen.has(hit.id)) continue; + seen.add(hit.id); + out.push(hit); + } + } + return out; +} diff --git a/src/internal/rateLimiter.ts b/src/internal/rateLimiter.ts new file mode 100644 index 0000000..1b80c25 --- /dev/null +++ b/src/internal/rateLimiter.ts @@ -0,0 +1,186 @@ +/** + * Per-host token-bucket rate limiter. + * + * Public catalogue APIs (AniList: 90 req/min, Jikan: 60 req/min + 3 req/s, + * MALSync: undocumented but courteous) need careful pacing in a server + * environment. This limiter answers `await acquire(hostname)` after waking + * any callers that have been queued past their bucket's capacity. + * + * Design notes: + * - Buckets are created lazily on first acquire so unknown hosts get the + * default policy. Unknown hosts can also fall through with no limit when + * no default is configured (current SDK default — limits only apply to + * metadata-layer hosts so we don't accidentally throttle stream CDNs). + * - The bucket's queue is FIFO. We could prioritize, but starvation is more + * of a risk than starvation-avoidance is a win here. + * - The implementation never busy-waits — sleeping waiters are resumed by + * `setTimeout`s scheduled at the precise moment the bucket regenerates. + */ + +export interface RateLimitConfig { + /** Maximum requests allowed in each `intervalMs` window. */ + capacity: number; + /** Window length in milliseconds. */ + intervalMs: number; + /** Optional secondary "burst" cap, e.g. Jikan's 3 req/s on top of 60/min. */ + burst?: { capacity: number; intervalMs: number }; +} + +export type PerHostRateLimits = Record; + +interface BucketState { + tokens: number; + windowStart: number; + config: RateLimitConfig; + burstTokens?: number; + burstWindowStart?: number; + queue: Array<{ resolve: () => void; reject: (e: unknown) => void; signal?: AbortSignal }>; + scheduled: boolean; +} + +export class RateLimiter { + private buckets = new Map(); + private defaultConfig?: RateLimitConfig; + private perHost: PerHostRateLimits; + + constructor(perHost: PerHostRateLimits = {}, defaultConfig?: RateLimitConfig) { + this.perHost = perHost; + this.defaultConfig = defaultConfig; + } + + /** + * Wait until a token is available for `hostname`. Resolves immediately if + * no policy applies (no per-host entry and no default). + * + * Honors `signal`: when aborted, the wait rejects with the signal's reason + * (or an AbortError) and the slot in the queue is released without + * granting a token. + */ + public async acquire(hostname: string, signal?: AbortSignal): Promise { + const cfg = this.perHost[hostname] ?? this.defaultConfig; + if (!cfg) return; // unlimited + if (signal?.aborted) throw abortError(signal); + + const bucket = this.getOrCreate(hostname, cfg); + this.refill(bucket); + + if (this.tryConsume(bucket)) return; + + return new Promise((resolve, reject) => { + const entry = { resolve, reject, signal }; + bucket.queue.push(entry); + if (signal) { + const onAbort = () => { + const idx = bucket.queue.indexOf(entry); + if (idx >= 0) bucket.queue.splice(idx, 1); + reject(abortError(signal)); + }; + if (signal.aborted) return onAbort(); + signal.addEventListener('abort', onAbort, { once: true }); + } + this.schedulePump(bucket); + }); + } + + /** + * Snapshot of the live state, useful for tests and observability. + */ + public snapshot(hostname: string): { tokens: number; queued: number } | null { + const bucket = this.buckets.get(hostname); + if (!bucket) return null; + this.refill(bucket); + return { tokens: bucket.tokens, queued: bucket.queue.length }; + } + + // ── internals ───────────────────────────────────────────────────────────── + + private getOrCreate(hostname: string, config: RateLimitConfig): BucketState { + let bucket = this.buckets.get(hostname); + if (!bucket) { + bucket = { + tokens: config.capacity, + windowStart: Date.now(), + config, + burstTokens: config.burst?.capacity, + burstWindowStart: config.burst ? Date.now() : undefined, + queue: [], + scheduled: false, + }; + this.buckets.set(hostname, bucket); + } + return bucket; + } + + /** Top up tokens whose window has elapsed. */ + private refill(b: BucketState): void { + const now = Date.now(); + if (now - b.windowStart >= b.config.intervalMs) { + b.tokens = b.config.capacity; + b.windowStart = now; + } + if (b.config.burst && b.burstWindowStart != null) { + if (now - b.burstWindowStart >= b.config.burst.intervalMs) { + b.burstTokens = b.config.burst.capacity; + b.burstWindowStart = now; + } + } + } + + private tryConsume(b: BucketState): boolean { + if (b.tokens <= 0) return false; + if (b.config.burst && (b.burstTokens ?? 0) <= 0) return false; + b.tokens -= 1; + if (b.config.burst) b.burstTokens = (b.burstTokens ?? 0) - 1; + return true; + } + + /** Schedule a wake-up at the next time we'd hand out at least one token. */ + private schedulePump(b: BucketState): void { + if (b.scheduled) return; + b.scheduled = true; + const now = Date.now(); + const waitMain = Math.max(0, b.config.intervalMs - (now - b.windowStart)); + const waitBurst = + b.config.burst && b.burstWindowStart != null + ? Math.max(0, b.config.burst.intervalMs - (now - b.burstWindowStart)) + : 0; + const wait = Math.max(1, Math.min(waitMain || 1, waitBurst || waitMain || 1)); + setTimeout(() => this.pump(b), wait).unref?.(); + } + + /** Drain as many waiters as the refilled bucket can satisfy. */ + private pump(b: BucketState): void { + b.scheduled = false; + this.refill(b); + while (b.queue.length > 0 && this.tryConsume(b)) { + const entry = b.queue.shift()!; + entry.resolve(); + } + if (b.queue.length > 0) this.schedulePump(b); + } +} + +function abortError(signal: AbortSignal): Error { + if (signal.reason instanceof Error) return signal.reason; + const e = new Error('Aborted'); + e.name = 'AbortError'; + return e; +} + +/** + * Default policies for catalogue + mapping APIs we ship support for. + * Stream CDNs are deliberately *not* listed — they have their own pacing + * needs that the provider/extractor knows better than us. + */ +export const DEFAULT_RATE_LIMITS: PerHostRateLimits = { + 'graphql.anilist.co': { capacity: 85, intervalMs: 60_000 }, + 'api.jikan.moe': { + capacity: 55, + intervalMs: 60_000, + burst: { capacity: 3, intervalMs: 1_000 }, + }, + 'kitsu.io': { capacity: 100, intervalMs: 60_000 }, + 'api.malsync.moe': { capacity: 30, intervalMs: 60_000 }, + 'api.anify.tv': { capacity: 30, intervalMs: 60_000 }, + 'arm.haglund.dev': { capacity: 60, intervalMs: 60_000 }, +}; diff --git a/src/internal/retry.ts b/src/internal/retry.ts new file mode 100644 index 0000000..06f0dca --- /dev/null +++ b/src/internal/retry.ts @@ -0,0 +1,164 @@ +/** + * Generic retry-with-backoff helper. + * + * Wraps an async operation in an exponential-backoff loop, honoring `429` + * Retry-After hints when present. Used by `HttpClient.request` to recover + * from transient upstream errors without the caller writing the loop. + * + * Treated as retryable: + * - Network errors (`TypeError: fetch failed`, ECONNRESET, ETIMEDOUT, …). + * - Status codes in `retryStatuses` (default 408, 425, 429, 500, 502, 503, 504). + * + * Aborted signals short-circuit without retrying. + */ + +export interface RetryConfig { + /** Maximum number of attempts (including the first). Default 3. */ + maxAttempts?: number; + /** Initial backoff in ms. Default 250. */ + initialDelayMs?: number; + /** Cap on per-attempt backoff in ms. Default 8_000. */ + maxDelayMs?: number; + /** Exponential factor; default 2. */ + factor?: number; + /** Jitter as a 0..1 fraction added to each delay. Default 0.25. */ + jitter?: number; + /** Status codes that signal "try again". */ + retryStatuses?: number[]; + /** Observer hook for each retry. */ + onRetry?: (info: { attempt: number; reason: string; delayMs: number }) => void; + /** Custom predicate; combined OR-style with the status/error defaults. */ + isRetryableError?: (err: unknown) => boolean; +} + +export const DEFAULT_RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504]; + +/** Used internally so the loop can read `Retry-After` off a successful-but-throttled response. */ +export class HttpRetryableError extends Error { + public readonly status: number; + public readonly retryAfterMs?: number; + constructor(status: number, retryAfterMs?: number) { + super(`HTTP ${status}`); + this.status = status; + this.retryAfterMs = retryAfterMs; + this.name = 'HttpRetryableError'; + } +} + +export async function withRetry( + fn: (attempt: number) => Promise, + config: RetryConfig = {}, + signal?: AbortSignal, +): Promise { + const maxAttempts = config.maxAttempts ?? 3; + const initial = config.initialDelayMs ?? 250; + const max = config.maxDelayMs ?? 8_000; + const factor = config.factor ?? 2; + const jitter = clamp01(config.jitter ?? 0.25); + + let attempt = 0; + let lastErr: unknown; + while (attempt < maxAttempts) { + if (signal?.aborted) throw abortError(signal); + attempt += 1; + try { + return await fn(attempt); + } catch (err) { + lastErr = err; + if (signal?.aborted) throw abortError(signal); + if (!isRetryable(err, config)) throw err; + if (attempt >= maxAttempts) throw err; + + const hinted = err instanceof HttpRetryableError ? err.retryAfterMs : undefined; + const expBackoff = Math.min(max, initial * factor ** (attempt - 1)); + const noise = jitter > 0 ? expBackoff * jitter * Math.random() : 0; + const delayMs = Math.max(0, hinted ?? expBackoff + noise); + + config.onRetry?.({ + attempt, + reason: err instanceof Error ? err.message : String(err), + delayMs, + }); + await sleep(delayMs, signal); + } + } + throw lastErr; +} + +function isRetryable(err: unknown, config: RetryConfig): boolean { + if (config.isRetryableError?.(err)) return true; + if (err instanceof HttpRetryableError) { + return (config.retryStatuses ?? DEFAULT_RETRY_STATUSES).includes(err.status); + } + // Network-level errors. The shape varies across Node versions/runtimes — + // matching on name/message/code covers the common cases. + if (err instanceof Error) { + const name = err.name; + if (name === 'AbortError') return false; // explicit aborts are not retryable + if (name === 'TypeError' && /fetch failed|network/i.test(err.message)) return true; + if ( + 'code' in err && + typeof (err as { code?: unknown }).code === 'string' && + [ + 'ECONNRESET', + 'ECONNREFUSED', + 'ETIMEDOUT', + 'EAI_AGAIN', + 'EPIPE', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'UND_ERR_SOCKET', + ].includes((err as { code?: string }).code!) + ) { + return true; + } + } + return false; +} + +/** Parse `Retry-After` (seconds or HTTP date) to milliseconds. */ +export function parseRetryAfter(value: string | null): number | undefined { + if (!value) return undefined; + const seconds = Number(value); + if (!Number.isNaN(seconds)) return seconds * 1000; + const date = Date.parse(value); + if (!Number.isNaN(date)) { + const ms = date - Date.now(); + return ms > 0 ? ms : 0; + } + return undefined; +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + if (ms <= 0) return Promise.resolve(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + timer.unref?.(); + const onAbort = () => { + cleanup(); + reject(abortError(signal!)); + }; + const cleanup = () => { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + }; + if (signal) { + if (signal.aborted) return onAbort(); + signal.addEventListener('abort', onAbort, { once: true }); + } + }); +} + +function clamp01(n: number): number { + return Math.max(0, Math.min(1, n)); +} + +function abortError(signal: AbortSignal): Error { + if (signal.reason instanceof Error) return signal.reason; + const e = new Error('Aborted'); + e.name = 'AbortError'; + return e; +} diff --git a/src/internal/transport.ts b/src/internal/transport.ts new file mode 100644 index 0000000..f797843 --- /dev/null +++ b/src/internal/transport.ts @@ -0,0 +1,196 @@ +/** + * Pluggable transport interface for `HttpClient`. + * + * The default {@link FetchTransport} is `fetch` with a curl fallback for + * Node — but the contract is just `(url, init) → Promise` so + * consumers can substitute anything they like (a custom Undici dispatcher, + * a Cloudflare-bypass service, an in-process test transport). + * + * Keeping the curl fallback behind this interface lets it be swapped out + * cleanly when it's not wanted — e.g. on Workers / Deno where `child_process` + * isn't available, or in tests that want to assert deterministic transport + * behaviour. + */ +export interface HttpTransport { + /** + * Perform one HTTP request. Implementations must: + * - honour `init.signal` (cancellation) + * - apply `init.headers` literally + * - return a real `Response` (or compatible shape) regardless of HTTP status + */ + fetch(url: string, init: RequestInit): Promise; +} + +/** + * Default browser-style transport — wraps the platform `fetch`. No fallback, + * no curl. Used in browsers and in Node when the caller opts out of the + * curl fallback via `HttpClientConfig.transport`. + */ +export class FetchTransport implements HttpTransport { + fetch(url: string, init: RequestInit): Promise { + return fetch(url, init); + } +} + +/** + * Fetch-with-curl-fallback transport. Tries `fetch` first; on network + * error (timeout, TLS quirk, anti-bot rejection) falls back to spawning + * `curl` via `child_process` and synthesising a `Response`-shaped object + * from its output. + * + * Only available in Node — `child_process` isn't usable in the browser, + * Workers, or Deno's sandboxed runtimes. The fallback no-ops in those + * environments and the original `fetch` error propagates. + * + * Per-instance cookie jar (`cookieFile`) is reused across calls so a site + * that sets a cookie on call 1 carries it on call 2. + */ +export class CurlFallbackTransport implements HttpTransport { + private cookieFile?: string; + private readonly timeoutMs: number; + + constructor(options: { timeoutMs?: number } = {}) { + this.timeoutMs = options.timeoutMs ?? 10_000; + } + + async fetch(url: string, init: RequestInit): Promise { + try { + return await fetch(url, init); + } catch (err: any) { + // Explicit aborts must propagate immediately. + if (init.signal?.aborted || (err?.name === 'AbortError' && init.signal)) { + throw err; + } + // Only attempt curl in Node. + if (typeof process === 'undefined' || !process.versions?.node) { + throw err; + } + try { + return await this.curlFetch(url, init); + } catch { + throw err; + } + } + } + + private async curlFetch(targetUrl: string, options: RequestInit): Promise { + const cp = await import('child_process'); + const execSync = cp.execSync; + + if (!this.cookieFile) { + try { + const os = await import('os'); + const path = await import('path'); + this.cookieFile = path.join( + os.tmpdir(), + `ani-sdk-cookie-${Math.random().toString(36).substring(2)}.txt`, + ); + } catch { + this.cookieFile = `/tmp/ani-sdk-cookie-${Math.random().toString(36).substring(2)}.txt`; + } + } + + const method = options.method || 'GET'; + const headers: Record = {}; + if (options.headers instanceof Headers) { + options.headers.forEach((v, k) => { + headers[k] = v; + }); + } else if (Array.isArray(options.headers)) { + for (const [k, v] of options.headers) headers[k] = v; + } else if (options.headers) { + Object.assign(headers, options.headers); + } + if (options.body instanceof URLSearchParams && !headers['Content-Type']) { + headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8'; + } + + let headerArgs = ''; + for (const [key, val] of Object.entries(headers)) { + headerArgs += ` -H ${JSON.stringify(`${key}: ${val}`)}`; + } + + let bodyArg = ''; + if (options.body) { + let bodyStr = ''; + if (typeof options.body === 'string') { + bodyStr = options.body; + } else if (options.body instanceof URLSearchParams) { + bodyStr = options.body.toString(); + } else { + bodyStr = JSON.stringify(options.body); + } + bodyArg = ` -d ${JSON.stringify(bodyStr)}`; + } + + const methodArg = method !== 'GET' && method !== 'POST' ? ` -X ${method}` : ''; + const cookieArg = ` -c ${JSON.stringify(this.cookieFile)} -b ${JSON.stringify(this.cookieFile)}`; + const cmd = `curl -sL --max-time ${Math.ceil(this.timeoutMs / 1000)}${methodArg}${headerArgs}${bodyArg}${cookieArg} -i ${JSON.stringify(targetUrl)}`; + const output = execSync(cmd, { maxBuffer: 10 * 1024 * 1024 }); + return parseCurlResponse(output.toString('binary'), targetUrl); + } +} + +/** + * Parse curl's `-i` (include headers) response into something + * `Response`-shaped. Handles 1xx/redirect chains by keeping only the + * last HTTP block. + */ +function parseCurlResponse(raw: string, targetUrl: string): Response { + const parts = raw.split('\r\n\r\n'); + let headerSection = ''; + let body = ''; + for (let i = 0; i < parts.length; i++) { + if (parts[i].startsWith('HTTP/')) { + headerSection = parts[i]; + body = parts.slice(i + 1).join('\r\n\r\n'); + } + } + const headerLines = headerSection.split('\r\n'); + const statusLine = headerLines[0]; + const m = statusLine.match(/HTTP\/\d+(\.\d+)?\s+(\d+)/); + const status = m ? parseInt(m[2], 10) : 200; + + const responseHeaders = new Headers(); + for (let i = 1; i < headerLines.length; i++) { + const line = headerLines[i]; + const idx = line.indexOf(':'); + if (idx !== -1) { + responseHeaders.append(line.substring(0, idx).trim(), line.substring(idx + 1).trim()); + } + } + + // Follow Location across the redirect chain for `Response.url`. + let finalUrl = targetUrl; + for (const part of parts) { + const lines = part.split('\r\n'); + if (lines[0].startsWith('HTTP/')) { + for (const line of lines) { + const idx = line.indexOf(':'); + if (idx === -1) continue; + const key = line.substring(0, idx).trim().toLowerCase(); + if (key !== 'location') continue; + const val = line.substring(idx + 1).trim(); + try { + finalUrl = val.startsWith('http') ? val : new URL(val, finalUrl).toString(); + } catch { + /* leave finalUrl as-is */ + } + } + } + } + + return { + status, + statusText: 'OK', + ok: status >= 200 && status < 300, + headers: responseHeaders, + url: finalUrl, + text: async () => body, + json: async () => JSON.parse(body), + arrayBuffer: async () => { + const buf = Buffer.from(body, 'binary'); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); + }, + } as unknown as Response; +} diff --git a/src/meta/MappingClient.ts b/src/meta/MappingClient.ts index 6eeea8a..989cecf 100644 --- a/src/meta/MappingClient.ts +++ b/src/meta/MappingClient.ts @@ -1,563 +1 @@ -import { HttpClient } from '../transport/http.js'; -import { BaseProvider, CallOptions } from '../providers/BaseProvider.js'; -import { - IMediaMappings, - IMediaMetadata, - IMediaSearchResult, - IMediaTitle, - SdkCache, -} from '../types/index.js'; -import { unwrapUrn } from '../utils/urn.js'; -import { bestSimilarity, normalizeTitle } from './similarity.js'; - -/** - * Cross-source ID resolver. - * - * Given an `IMediaMetadata` record (`anilist:21`) and a `BaseProvider` - * (`allmangaProvider`), returns the raw media ID that provider uses for - * the same title — applying as much rigour as the data allows: - * - * 1. **Provider-native lookup** (`provider.lookupByMapping`) — when a - * site indexes its catalogue by AniList/MAL ID directly, that's - * authoritative and cheapest. Hook is optional per provider. - * 2. **External mapping APIs** raced in parallel: MALSync, Anify, and - * arm-server. First non-empty result wins; the others get cached for - * later providers that need them. Provider names are taken from - * `Provider.malsyncSites` / fallback aliases. - * 3. **Fuzzy title search** as a final fallback. Searches the provider - * with multiple title variants in parallel, then ranks every - * candidate by a composite similarity metric, with `year` and - * `catalogType` discriminators applied as hard filters. When the top - * candidate is borderline (similarity within `verifyBand` of the - * threshold), we issue one extra `fetchContentUnits` round-trip to - * cross-check `episodeCount` before accepting. - * - * Results are persisted to an `SdkCache` keyed by - * `mapping:${metaProvider}:${metaNativeId}:${contentProvider}`. The - * metadata object is **never** mutated — that surprised callers and made - * `SdkCache`-cached metadata records dangerous to share across calls. - */ -export interface MappingClientOptions { - /** Optional read/write cache for resolved mappings (and external API responses). */ - cache?: SdkCache; - /** Disable MALSync lookups. */ - disableMalsync?: boolean; - /** Disable Anify lookups. */ - disableAnify?: boolean; - /** Disable arm-server lookups (only useful for anime). */ - disableArmServer?: boolean; - /** Minimum composite similarity to accept a fuzzy match. 0–1; default 0.78. */ - minSimilarity?: number; - /** - * Width of the "borderline" band below the threshold within which we - * trigger the episode-count cross-check. Default 0.07 — i.e. with - * `minSimilarity: 0.78`, matches between 0.71 and 0.85 get verified. - */ - verifyBand?: number; - /** Tolerance for the year discriminator. Default 2. */ - yearTolerance?: number; - /** Tolerance for the episode-count discriminator. Default 3. */ - episodeCountTolerance?: number; - /** Max search candidates per provider call. Default 12. */ - fuzzyCandidateLimit?: number; - /** Max parallel `provider.search` calls during fuzzy match. Default 4. */ - fuzzyConcurrency?: number; -} - -export type MappingMethod = 'cached' | 'provider' | 'malsync' | 'anify' | 'arm' | 'fuzzy'; - -export interface MappingResolution { - providerId: string; - rawMediaId: string; - matchedTitle: string; - method: MappingMethod; - similarity?: number; -} - -/** - * Provider-id aliases for external mapping services. Used when a provider - * doesn't declare its own `malsyncSites` / `anifySites` (which it should - * — these constants are the fallback safety net for the SDK's own - * built-in providers). - */ -const BUILT_IN_MALSYNC_ALIASES: Record = { - mangadex: ['Mangadex', 'MangaDex'], - mangapill: ['Mangapill'], - weebcentral: ['Weebcentral', 'WeebCentral'], -}; - -interface MalsyncResponse { - malId?: number; - anilistId?: number; - Sites?: Record< - string, - Record - >; -} - -interface AnifyMappingEntry { - id: string; - providerId: string; - providerType?: 'ANIME' | 'MANGA'; -} - -interface AnifyResponse { - mappings?: AnifyMappingEntry[]; - episodeCount?: number; -} - -interface ArmServerResponse { - anilist?: number; - mal?: number; - kitsu?: number; - anidb?: number; - notify?: string; - livechart?: number; -} - -export class MappingClient { - constructor( - private http: HttpClient, - private options: MappingClientOptions = {}, - ) {} - - /** - * Resolve `metadata` → raw media ID on `contentProvider`. - * - * Returns `null` when no resolution method finds a confident match. The - * input `metadata` object is **never mutated**. - */ - public async resolveProviderMediaId( - metadata: IMediaMetadata, - contentProvider: BaseProvider, - options: CallOptions = {}, - ): Promise { - const cacheKey = mappingCacheKey(metadata, contentProvider); - // ── 0. SdkCache ─────────────────────────────────────────────────────── - if (this.options.cache) { - const hit = await this.options.cache.get(cacheKey); - if (hit !== undefined && hit !== null) { - const stored = hit as MappingResolution; - return { ...stored, method: 'cached' }; - } - } - - // ── 1. Provider-native lookup ───────────────────────────────────────── - if (contentProvider.lookupByMapping && metadata.mappings) { - try { - const raw = await contentProvider.lookupByMapping(metadata.mappings, options); - if (raw) - return this.acceptAndCache( - cacheKey, - contentProvider, - raw, - displayTitle(metadata), - 'provider', - ); - } catch { - // Fall through — provider-side lookup is best-effort. - } - } - - // ── 2. External mapping APIs (raced) ────────────────────────────────── - const ext = await this.resolveFromExternalMappings(metadata, contentProvider, options); - if (ext) { - return this.acceptAndCache( - cacheKey, - contentProvider, - ext.rawId, - displayTitle(metadata), - ext.method, - ); - } - - // ── 3. Fuzzy search ─────────────────────────────────────────────────── - const fuzzy = await this.fuzzyMatch(metadata, contentProvider, options); - if (fuzzy) { - const cached = await this.acceptAndCache( - cacheKey, - contentProvider, - fuzzy.rawMediaId, - fuzzy.matchedTitle, - 'fuzzy', - ); - return { ...cached, similarity: fuzzy.similarity }; - } - - return null; - } - - // ── External APIs ───────────────────────────────────────────────────────── - - /** - * Race MALSync, Anify, and arm-server. The first one to return a - * matching alias for `contentProvider` wins; the others continue in the - * background and their answers are cached on the SdkCache so a *future* - * lookup for a different content provider can pick them up cheaply. - */ - private async resolveFromExternalMappings( - metadata: IMediaMetadata, - contentProvider: BaseProvider, - options: CallOptions, - ): Promise<{ rawId: string; method: MappingMethod } | null> { - const type: 'anime' | 'manga' = metadata.catalogType === 'MANGA' ? 'manga' : 'anime'; - const malsyncAliases = providerMalsyncAliases(contentProvider); - - const tasks: Array<{ method: MappingMethod; promise: Promise }> = []; - - if (!this.options.disableMalsync && (metadata.mappings?.anilist || metadata.mappings?.mal)) { - tasks.push({ - method: 'malsync', - promise: this.lookupMalsync(metadata, type, malsyncAliases, contentProvider.id, options), - }); - } - if (!this.options.disableAnify && (metadata.mappings?.anilist || metadata.mappings?.mal)) { - tasks.push({ - method: 'anify', - promise: this.lookupAnify(metadata, contentProvider.id, options), - }); - } - if (!this.options.disableArmServer && type === 'anime' && metadata.mappings?.anilist) { - tasks.push({ - method: 'arm', - promise: this.enrichMappingsViaArm(metadata, options).then(() => null), - // arm doesn't return provider-specific IDs — it gives us cross-source - // catalogue IDs (kitsu, anidb, notify, …) that we cache for later - // mapping-API calls. We never accept its result directly here. - }); - } - - if (tasks.length === 0) return null; - - // Race: resolve as soon as any task returns a non-null value. - // We can't use Promise.any (rejects ≠ no-match), so do it manually. - return new Promise<{ rawId: string; method: MappingMethod } | null>((resolve) => { - let remaining = tasks.length; - for (const { method, promise } of tasks) { - promise - .then((rawId) => { - if (rawId) resolve({ rawId, method }); - }) - .catch(() => {}) - .finally(() => { - remaining -= 1; - if (remaining === 0) resolve(null); - }); - } - }); - } - - private async lookupMalsync( - metadata: IMediaMetadata, - type: 'anime' | 'manga', - aliases: string[], - contentProviderId: string, - options: CallOptions, - ): Promise { - const anilistId = metadata.mappings?.anilist; - const malId = metadata.mappings?.mal; - const namespace = anilistId ? 'anilist' : 'mal'; - const id = anilistId ?? malId; - if (!id) return null; - const url = `https://api.malsync.moe/${namespace}/${type}/${id}`; - try { - const res = await this.http.get(url, { - headers: { Accept: 'application/json' }, - signal: options.signal, - }); - if (res.status !== 200) return null; - const data = (await res.json()) as MalsyncResponse; - const sites = data?.Sites; - if (!sites) return null; - for (const alias of aliases) { - const bucket = sites[alias]; - if (!bucket) continue; - const first = Object.values(bucket)[0]; - const raw = String(first?.identifier ?? '').trim(); - if (raw) return raw; - } - // Also stash any other provider hits we found in the cache so - // future lookups for those providers can short-circuit. - await this.stashSiblingHits(metadata, sites); - return null; - } catch { - return null; - } - } - - private async lookupAnify( - metadata: IMediaMetadata, - contentProviderId: string, - options: CallOptions, - ): Promise { - const anilistId = metadata.mappings?.anilist; - if (!anilistId) return null; - const url = `https://api.anify.tv/info/${anilistId}`; - try { - const res = await this.http.get(url, { - headers: { Accept: 'application/json' }, - signal: options.signal, - }); - if (res.status !== 200) return null; - const data = (await res.json()) as AnifyResponse; - const m = data?.mappings ?? []; - const hit = m.find((x) => x.providerId === contentProviderId); - return hit?.id ?? null; - } catch { - return null; - } - } - - /** - * arm-server doesn't speak content-provider IDs — it speaks catalogue - * IDs (kitsu, anidb, notify, livechart). We call it not to *resolve* - * but to *enrich* the in-memory mappings so subsequent MALSync/Anify - * lookups have more keys to try. Cached on the SdkCache. - */ - private async enrichMappingsViaArm( - metadata: IMediaMetadata, - options: CallOptions, - ): Promise { - const anilistId = metadata.mappings?.anilist; - if (!anilistId) return null; - const cacheKey = `arm:anilist:${anilistId}`; - if (this.options.cache) { - const hit = await this.options.cache.get(cacheKey); - if (hit !== undefined && hit !== null) return hit as IMediaMappings; - } - try { - const res = await this.http.get( - `https://arm.haglund.dev/api/v2/ids?source=anilist&id=${anilistId}`, - { headers: { Accept: 'application/json' }, signal: options.signal }, - ); - if (res.status !== 200) return null; - const data = (await res.json()) as ArmServerResponse; - const enriched: IMediaMappings = { - anilist: data.anilist, - mal: data.mal, - kitsu: data.kitsu, - anidb: data.anidb, - }; - if (this.options.cache) await this.options.cache.set(cacheKey, enriched); - return enriched; - } catch { - return null; - } - } - - /** Best-effort cache-warming: stash MALSync's other site hits in SdkCache. */ - private async stashSiblingHits( - metadata: IMediaMetadata, - sites: NonNullable, - ): Promise { - if (!this.options.cache) return; - // We can't know which BaseProvider.id a site corresponds to without - // querying the registered providers — but the matched site name is a - // stable key, so cache by `malsync:${siteName}:${anilistOrMal}:${id}` - // and let future MappingClient instances pick up. - const anilistId = metadata.mappings?.anilist; - const malId = metadata.mappings?.mal; - for (const [siteName, bucket] of Object.entries(sites)) { - const first = Object.values(bucket)[0]; - const raw = String(first?.identifier ?? '').trim(); - if (!raw) continue; - if (anilistId) await this.options.cache.set(`malsync:${siteName}:anilist:${anilistId}`, raw); - if (malId) await this.options.cache.set(`malsync:${siteName}:mal:${malId}`, raw); - } - } - - // ── Fuzzy matching ──────────────────────────────────────────────────────── - - private async fuzzyMatch( - metadata: IMediaMetadata, - contentProvider: BaseProvider, - options: CallOptions, - ): Promise { - const threshold = this.options.minSimilarity ?? 0.78; - const verifyBand = this.options.verifyBand ?? 0.07; - const limit = this.options.fuzzyCandidateLimit ?? 12; - const concurrency = this.options.fuzzyConcurrency ?? 4; - const yearTol = this.options.yearTolerance ?? 2; - - const queries = uniqueQueries([ - metadata.title.userPreferred, - metadata.title.english, - metadata.title.romaji, - metadata.title.native, - ...(metadata.synonyms ?? []), - ]); - if (queries.length === 0) return null; - - const candidates = await runParallelSearches( - contentProvider, - queries.slice(0, concurrency), - limit, - options, - ); - if (candidates.length === 0) return null; - - const altTitles = [ - metadata.title.userPreferred, - metadata.title.english, - metadata.title.romaji, - metadata.title.native, - ...(metadata.synonyms ?? []), - ]; - - // Score every candidate with all known discriminators applied. - type Scored = { - result: IMediaSearchResult; - score: number; - catalogMatch: boolean; - yearMatch: boolean; - }; - const scored: Scored[] = candidates.map((c) => ({ - result: c, - score: bestSimilarity(c.title, altTitles), - catalogMatch: c.catalogType === metadata.catalogType, - yearMatch: yearIsCompatible(metadata.year, getCandidateYear(c), yearTol), - })); - - // Hard filters first. - const filtered = scored.filter((s) => s.catalogMatch && s.yearMatch); - const pool = filtered.length > 0 ? filtered : scored; - - pool.sort((a, b) => b.score - a.score); - const top = pool[0]; - if (!top || top.score < threshold - verifyBand) return null; - - const raw = unwrapUrn(contentProvider.id, top.result.id); - - // High-confidence: above threshold and catalogType matches → accept directly. - if (top.score >= threshold && top.catalogMatch && top.yearMatch) { - return makeRes(contentProvider, raw, top); - } - - // Borderline → cross-check episode count. - if (typeof metadata.episodeCount === 'number') { - try { - const units = await contentProvider.fetchContentUnits(top.result.id, options); - const actualCount = units.length; - const expected = metadata.episodeCount; - const tol = this.options.episodeCountTolerance ?? 3; - if (Math.abs(actualCount - expected) <= tol) { - return makeRes(contentProvider, raw, top); - } - } catch { - // Provider blew up — fall back to similarity threshold alone. - } - } - - // No cross-check possible; accept only if clearly above threshold. - if (top.score >= threshold) return makeRes(contentProvider, raw, top); - return null; - } - - // ── Acceptance / persistence helpers ────────────────────────────────────── - - private async acceptAndCache( - cacheKey: string, - contentProvider: BaseProvider, - rawMediaId: string, - matchedTitle: string, - method: MappingMethod, - ): Promise { - const resolution: MappingResolution = { - providerId: contentProvider.id, - rawMediaId, - matchedTitle, - method, - }; - if (this.options.cache) await this.options.cache.set(cacheKey, resolution); - return resolution; - } -} - -// ── Helpers ───────────────────────────────────────────────────────────────── - -function mappingCacheKey(metadata: IMediaMetadata, contentProvider: BaseProvider): string { - return `mapping:${metadata.providerId}:${unwrapUrn(metadata.providerId, metadata.id)}:${contentProvider.id}`; -} - -function displayTitle(m: IMediaMetadata): string { - return m.title.userPreferred ?? m.title.english ?? m.title.romaji ?? ''; -} - -function uniqueQueries(raw: Array): string[] { - const out: string[] = []; - const seen = new Set(); - for (const q of raw) { - if (!q) continue; - const norm = normalizeTitle(q); - if (!norm || seen.has(norm)) continue; - seen.add(norm); - out.push(q); - } - return out; -} - -/** - * Read the provider's MALSync aliases — first from a `malsyncSites` static - * property on the constructor, then from `BUILT_IN_MALSYNC_ALIASES`. - * Returns at least one entry (the provider's own id, lowercased and - * capitalized) so single-word provider IDs work without configuration. - */ -function providerMalsyncAliases(provider: BaseProvider): string[] { - const ctor = provider.constructor as unknown as { malsyncSites?: readonly string[] }; - if (ctor.malsyncSites && ctor.malsyncSites.length > 0) return [...ctor.malsyncSites]; - const builtIn = BUILT_IN_MALSYNC_ALIASES[provider.id]; - if (builtIn) return builtIn; - return [capitalize(provider.id)]; -} - -function capitalize(s: string): string { - if (!s) return s; - return s[0].toUpperCase() + s.slice(1); -} - -function yearIsCompatible( - expected: number | undefined, - actual: number | undefined, - tolerance: number, -): boolean { - if (expected == null || actual == null) return true; // unknown → not a hard filter - return Math.abs(expected - actual) <= tolerance; -} - -function getCandidateYear(c: IMediaSearchResult): number | undefined { - return c.year; -} - -function makeRes( - contentProvider: BaseProvider, - raw: string, - s: { result: IMediaSearchResult; score: number }, -): MappingResolution { - return { - providerId: contentProvider.id, - rawMediaId: raw, - matchedTitle: s.result.title, - method: 'fuzzy', - similarity: s.score, - }; -} - -async function runParallelSearches( - contentProvider: BaseProvider, - queries: string[], - perQueryLimit: number, - options: CallOptions, -): Promise { - const results = await Promise.allSettled(queries.map((q) => contentProvider.search(q, options))); - const seen = new Set(); - const out: IMediaSearchResult[] = []; - for (const r of results) { - if (r.status !== 'fulfilled') continue; - for (const hit of r.value.slice(0, perQueryLimit)) { - if (seen.has(hit.id)) continue; - seen.add(hit.id); - out.push(hit); - } - } - return out; -} +export * from '../internal/mapping.js'; diff --git a/src/progressive.ts b/src/progressive.ts new file mode 100644 index 0000000..c0e7c80 --- /dev/null +++ b/src/progressive.ts @@ -0,0 +1 @@ +// Phase 5: ProgressiveResult — AsyncIterable & PromiseLike diff --git a/src/registry.ts b/src/registry.ts new file mode 100644 index 0000000..7d8faad --- /dev/null +++ b/src/registry.ts @@ -0,0 +1 @@ +// Phase 4: Registry — sourcesFor, fanOutSearch, mergeEpisodes, rankPlaybackSources diff --git a/src/sdk.ts b/src/sdk.ts new file mode 100644 index 0000000..7aa2e0f --- /dev/null +++ b/src/sdk.ts @@ -0,0 +1 @@ +// Phase 7: Sdk class — createSdk() and the 9 verbs diff --git a/src/transport/dom.ts b/src/transport/dom.ts index eea464b..52940e3 100644 --- a/src/transport/dom.ts +++ b/src/transport/dom.ts @@ -1,69 +1 @@ -import { DOMParser as LinkedomParser } from 'linkedom'; -import { IDomElement, IDomParser } from '../types/index.js'; - -// Auto-register linkedom in environments without a native DOMParser (Node, Bun). -// Skipped if a native DOMParser is present (browsers) or a custom one was already -// set, so DomRegistry.register() still takes full precedence. -if (typeof globalThis.DOMParser === 'undefined') { - (globalThis as any).DOMParser = LinkedomParser; -} - -export class BrowserDomElement implements IDomElement { - constructor(private element: Element) {} - - public querySelector(selector: string): IDomElement | null { - const el = this.element.querySelector(selector); - return el ? new BrowserDomElement(el) : null; - } - - public querySelectorAll(selector: string): IDomElement[] { - const els = this.element.querySelectorAll(selector); - return Array.from(els).map((el) => new BrowserDomElement(el)); - } - - public getAttribute(name: string): string | null { - return this.element.getAttribute(name); - } - - public get textContent(): string | null { - return this.element.textContent; - } - - public get outerHTML(): string { - return this.element.outerHTML; - } - - public get innerHTML(): string { - return this.element.innerHTML; - } -} - -export class BrowserDomParser implements IDomParser { - public parse(html: string): IDomElement { - if (typeof globalThis.DOMParser === 'undefined') { - throw new Error( - 'DOMParser is not available in this environment. Please register a custom DOM Parser via DomRegistry.register().', - ); - } - const parser = new globalThis.DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); - // Ensure we start from documentElement or body if needed - return new BrowserDomElement(doc.documentElement || doc.body); - } -} - -export class DomRegistry { - private static parser: IDomParser = new BrowserDomParser(); - - public static register(customParser: IDomParser): void { - this.parser = customParser; - } - - public static getParser(): IDomParser { - return this.parser; - } - - public static parse(html: string): IDomElement { - return this.parser.parse(html); - } -} +export * from '../internal/dom.js'; diff --git a/src/transport/hlsUtils.ts b/src/transport/hlsUtils.ts index 8ba409d..1fe3c95 100644 --- a/src/transport/hlsUtils.ts +++ b/src/transport/hlsUtils.ts @@ -1,70 +1 @@ -import { HttpClient } from './http.js'; - -export class HlsUtils { - /** - * Rewrites chunk and sub-playlist URLs in an M3U8 playlist to route through the proxy. - * @param manifestText Raw M3U8 content - * @param playlistUrl Original URL of the M3U8 file (used to resolve relative paths) - * @param httpClient HttpClient instance to obtain proxy configuration - */ - public static rewriteManifest( - manifestText: string, - playlistUrl: string, - httpClient: HttpClient, - ): string { - if (!httpClient.getProxyUrl()) { - return manifestText; - } - - const lines = manifestText.split(/\r?\n/); - const rewrittenLines = lines.map((line) => { - const trimmed = line.trim(); - // Parse URI="..." in tags like #EXT-X-KEY or #EXT-X-MAP - if (trimmed.startsWith('#')) { - return this.rewriteTagsWithUris(trimmed, playlistUrl, httpClient); - } - if (trimmed.length === 0) { - return line; - } - // This is a URI line (either a chunk or a sub-playlist) - const absoluteUrl = this.resolveUrl(playlistUrl, trimmed); - return httpClient.requestUrl(absoluteUrl); - }); - - return rewrittenLines.join('\n'); - } - - /** - * Resolves relative URLs against a base URL - */ - private static resolveUrl(base: string, relative: string): string { - try { - return new URL(relative, base).href; - } catch { - if (relative.startsWith('http://') || relative.startsWith('https://')) { - return relative; - } - const lastSlash = base.lastIndexOf('/'); - if (lastSlash === -1) return relative; - const basePath = base.substring(0, lastSlash + 1); - return `${basePath}${relative}`; - } - } - - /** - * Helper to rewrite inline URIs in tags like #EXT-X-KEY:METHOD=AES-128,URI="key.key" - */ - private static rewriteTagsWithUris( - line: string, - playlistUrl: string, - httpClient: HttpClient, - ): string { - // Matches URI="value" or URI='value' - const uriRegex = /URI=(["'])(.*?)\1/g; - return line.replace(uriRegex, (match, quote, uri) => { - const absoluteUrl = this.resolveUrl(playlistUrl, uri); - const proxiedUrl = httpClient.requestUrl(absoluteUrl); - return `URI=${quote}${proxiedUrl}${quote}`; - }); - } -} +export * from '../internal/hls.js'; diff --git a/src/transport/http.ts b/src/transport/http.ts index 7866fed..b4e5e66 100644 --- a/src/transport/http.ts +++ b/src/transport/http.ts @@ -1,261 +1 @@ -import { - DEFAULT_RATE_LIMITS, - PerHostRateLimits, - RateLimitConfig, - RateLimiter, -} from './rateLimiter.js'; -import { - DEFAULT_RETRY_STATUSES, - HttpRetryableError, - RetryConfig, - parseRetryAfter, - withRetry, -} from './retry.js'; -import { CurlFallbackTransport, HttpTransport } from './transport.js'; - -export interface HttpClientConfig { - proxyUrl?: string; - proxyType?: 'prepend' | 'query'; - proxyQueryParam?: string; - defaultHeaders?: Record; - timeoutMs?: number; - /** - * Per-host token-bucket rate limits. Merged on top of - * {@link DEFAULT_RATE_LIMITS}, which covers AniList / Jikan / Kitsu / - * MALSync / Anify / arm-server with their published quotas. Pass an - * empty object to start blank. - */ - rateLimits?: PerHostRateLimits; - /** Optional default policy for hosts not in the per-host map. */ - defaultRateLimit?: RateLimitConfig; - /** Disable rate limiting entirely (e.g. in tests). */ - disableRateLimit?: boolean; - /** - * Retry policy. Defaults to 3 attempts with exponential backoff (250ms - * base) on 408/425/429/5xx and transient network errors. Honours - * `Retry-After`. - */ - retry?: RetryConfig | false; - /** - * Pluggable transport. Defaults to {@link CurlFallbackTransport} on - * Node (fetch + curl fallback) and degrades to a plain `fetch` on - * runtimes that don't expose `child_process`. Pass a {@link FetchTransport} - * to disable the curl fallback explicitly, or any custom - * {@link HttpTransport} for full control (e.g. an Undici dispatcher, - * a Cloudflare-bypass proxy, or an in-process test transport). - */ - transport?: HttpTransport; -} - -export class HttpClient { - private proxyUrl?: string; - private proxyType: 'prepend' | 'query'; - private proxyQueryParam: string; - private defaultHeaders: Record; - private timeoutMs: number; - private rateLimiter?: RateLimiter; - private retryConfig: RetryConfig | false; - private transport: HttpTransport; - - constructor(config: HttpClientConfig = {}) { - this.proxyUrl = config.proxyUrl; - this.proxyType = config.proxyType || 'prepend'; - this.proxyQueryParam = config.proxyQueryParam || 'url'; - this.defaultHeaders = config.defaultHeaders || {}; - this.timeoutMs = config.timeoutMs || 10000; - if (!config.disableRateLimit) { - this.rateLimiter = new RateLimiter( - { ...DEFAULT_RATE_LIMITS, ...(config.rateLimits ?? {}) }, - config.defaultRateLimit, - ); - } - this.retryConfig = config.retry === false ? false : (config.retry ?? {}); - this.transport = config.transport ?? new CurlFallbackTransport({ timeoutMs: this.timeoutMs }); - } - - /** Live rate-limiter; useful for tests and observability. May be undefined when disabled. */ - public getRateLimiter(): RateLimiter | undefined { - return this.rateLimiter; - } - - public getTransport(): HttpTransport { - return this.transport; - } - - public getProxyUrl(): string | undefined { - return this.proxyUrl; - } - - public getProxyType(): 'prepend' | 'query' { - return this.proxyType; - } - - public getProxyQueryParam(): string { - return this.proxyQueryParam; - } - - public getDefaultHeaders(): Record { - return this.defaultHeaders; - } - - public requestUrl(url: string): string { - if (!this.proxyUrl) return url; - if (this.proxyType === 'prepend') { - const base = this.proxyUrl.endsWith('/') ? this.proxyUrl : `${this.proxyUrl}/`; - // Strip target protocol if the prepend proxy expects path prepending - // e.g. proxy.com/target.com/path - const target = url.replace(/^(https?:\/\/)/, ''); - return `${base}${target}`; - } else { - const separator = this.proxyUrl.includes('?') ? '&' : '?'; - return `${this.proxyUrl}${separator}${this.proxyQueryParam}=${encodeURIComponent(url)}`; - } - } - - public async request(url: string, options: RequestInit = {}): Promise { - const signal = options.signal as AbortSignal | null | undefined; - const host = safeHostname(this.requestUrl(url)); - - if (this.retryConfig === false) { - if (this.rateLimiter && host) await this.rateLimiter.acquire(host, signal ?? undefined); - return this.requestOnce(url, options); - } - return withRetry( - async () => { - // Re-acquire on every attempt — each fetch is a billable upstream - // call and must respect the per-host budget independently. Doing - // this outside the retry loop would let a noisy retry-storm - // silently blow past the configured rate limit. - if (this.rateLimiter && host) await this.rateLimiter.acquire(host, signal ?? undefined); - const res = await this.requestOnce(url, options); - const retryStatuses = - this.retryConfig === false - ? DEFAULT_RETRY_STATUSES - : (this.retryConfig.retryStatuses ?? DEFAULT_RETRY_STATUSES); - if (retryStatuses.includes(res.status)) { - const ra = parseRetryAfter(res.headers.get('retry-after')); - throw new HttpRetryableError(res.status, ra); - } - return res; - }, - this.retryConfig, - signal ?? undefined, - ); - } - - private async requestOnce(url: string, options: RequestInit = {}): Promise { - const targetUrl = this.requestUrl(url); - const headers: Record = { ...this.defaultHeaders }; - if (options.headers) { - if (options.headers instanceof Headers) { - options.headers.forEach((value, key) => { - headers[key] = value; - }); - } else if (Array.isArray(options.headers)) { - for (const [key, value] of options.headers) { - headers[key] = value; - } - } else { - Object.assign(headers, options.headers); - } - } - - // Compose the timeout signal with the caller's signal (if any) so a - // caller-supplied AbortSignal still cancels the in-flight request. - const callerSignal = options.signal as AbortSignal | null | undefined; - const controller = new AbortController(); - const id = setTimeout(() => controller.abort(new Error('Request timed out')), this.timeoutMs); - let onCallerAbort: (() => void) | undefined; - if (callerSignal) { - if (callerSignal.aborted) { - clearTimeout(id); - throw abortReason(callerSignal); - } - onCallerAbort = () => controller.abort(callerSignal.reason); - callerSignal.addEventListener('abort', onCallerAbort, { once: true }); - } - const cleanup = () => { - clearTimeout(id); - if (onCallerAbort) callerSignal!.removeEventListener('abort', onCallerAbort); - }; - - try { - const res = await this.transport.fetch(targetUrl, { - ...options, - headers, - signal: controller.signal, - }); - cleanup(); - return res; - } catch (err) { - cleanup(); - throw err; - } - } - - public async get(url: string, options: RequestInit = {}): Promise { - return this.request(url, { ...options, method: 'GET' }); - } - - public async post(url: string, body?: any, options: RequestInit = {}): Promise { - const headers: Record = {}; - if (options.headers) { - if (options.headers instanceof Headers) { - options.headers.forEach((value, key) => { - headers[key] = value; - }); - } else if (Array.isArray(options.headers)) { - for (const [key, value] of options.headers) { - headers[key] = value; - } - } else { - Object.assign(headers, options.headers); - } - } - let finalBody = body; - if ( - body && - typeof body === 'object' && - !(body instanceof FormData) && - !(body instanceof URLSearchParams) - ) { - if (!headers['Content-Type']) { - headers['Content-Type'] = 'application/json'; - } - finalBody = JSON.stringify(body); - } - return this.request(url, { ...options, method: 'POST', headers, body: finalBody }); - } - - public setCookie(name: string, value: string): void { - const existingCookie = this.defaultHeaders['Cookie'] || ''; - const cookies = existingCookie ? existingCookie.split(';').map((c) => c.trim()) : []; - const newCookies = cookies.filter((c) => !c.startsWith(`${name}=`)); - newCookies.push(`${name}=${value}`); - this.defaultHeaders['Cookie'] = newCookies.join('; '); - } - - public setUserAgent(userAgent: string): void { - this.defaultHeaders['User-Agent'] = userAgent; - } -} - -/** - * Extract the hostname from a URL for rate-limiter bucketing. Returns - * `undefined` if the URL is relative or unparseable — those calls aren't - * rate-limited. - */ -function safeHostname(url: string): string | undefined { - try { - return new URL(url).hostname; - } catch { - return undefined; - } -} - -function abortReason(signal: AbortSignal): Error { - if (signal.reason instanceof Error) return signal.reason; - const e = new Error(typeof signal.reason === 'string' ? signal.reason : 'Aborted'); - e.name = 'AbortError'; - return e; -} +export * from '../internal/http.js'; diff --git a/src/transport/rateLimiter.ts b/src/transport/rateLimiter.ts index 1b80c25..9ccb6f3 100644 --- a/src/transport/rateLimiter.ts +++ b/src/transport/rateLimiter.ts @@ -1,186 +1 @@ -/** - * Per-host token-bucket rate limiter. - * - * Public catalogue APIs (AniList: 90 req/min, Jikan: 60 req/min + 3 req/s, - * MALSync: undocumented but courteous) need careful pacing in a server - * environment. This limiter answers `await acquire(hostname)` after waking - * any callers that have been queued past their bucket's capacity. - * - * Design notes: - * - Buckets are created lazily on first acquire so unknown hosts get the - * default policy. Unknown hosts can also fall through with no limit when - * no default is configured (current SDK default — limits only apply to - * metadata-layer hosts so we don't accidentally throttle stream CDNs). - * - The bucket's queue is FIFO. We could prioritize, but starvation is more - * of a risk than starvation-avoidance is a win here. - * - The implementation never busy-waits — sleeping waiters are resumed by - * `setTimeout`s scheduled at the precise moment the bucket regenerates. - */ - -export interface RateLimitConfig { - /** Maximum requests allowed in each `intervalMs` window. */ - capacity: number; - /** Window length in milliseconds. */ - intervalMs: number; - /** Optional secondary "burst" cap, e.g. Jikan's 3 req/s on top of 60/min. */ - burst?: { capacity: number; intervalMs: number }; -} - -export type PerHostRateLimits = Record; - -interface BucketState { - tokens: number; - windowStart: number; - config: RateLimitConfig; - burstTokens?: number; - burstWindowStart?: number; - queue: Array<{ resolve: () => void; reject: (e: unknown) => void; signal?: AbortSignal }>; - scheduled: boolean; -} - -export class RateLimiter { - private buckets = new Map(); - private defaultConfig?: RateLimitConfig; - private perHost: PerHostRateLimits; - - constructor(perHost: PerHostRateLimits = {}, defaultConfig?: RateLimitConfig) { - this.perHost = perHost; - this.defaultConfig = defaultConfig; - } - - /** - * Wait until a token is available for `hostname`. Resolves immediately if - * no policy applies (no per-host entry and no default). - * - * Honors `signal`: when aborted, the wait rejects with the signal's reason - * (or an AbortError) and the slot in the queue is released without - * granting a token. - */ - public async acquire(hostname: string, signal?: AbortSignal): Promise { - const cfg = this.perHost[hostname] ?? this.defaultConfig; - if (!cfg) return; // unlimited - if (signal?.aborted) throw abortError(signal); - - const bucket = this.getOrCreate(hostname, cfg); - this.refill(bucket); - - if (this.tryConsume(bucket)) return; - - return new Promise((resolve, reject) => { - const entry = { resolve, reject, signal }; - bucket.queue.push(entry); - if (signal) { - const onAbort = () => { - const idx = bucket.queue.indexOf(entry); - if (idx >= 0) bucket.queue.splice(idx, 1); - reject(abortError(signal)); - }; - if (signal.aborted) return onAbort(); - signal.addEventListener('abort', onAbort, { once: true }); - } - this.schedulePump(bucket); - }); - } - - /** - * Snapshot of the live state, useful for tests and observability. - */ - public snapshot(hostname: string): { tokens: number; queued: number } | null { - const bucket = this.buckets.get(hostname); - if (!bucket) return null; - this.refill(bucket); - return { tokens: bucket.tokens, queued: bucket.queue.length }; - } - - // ── internals ───────────────────────────────────────────────────────────── - - private getOrCreate(hostname: string, config: RateLimitConfig): BucketState { - let bucket = this.buckets.get(hostname); - if (!bucket) { - bucket = { - tokens: config.capacity, - windowStart: Date.now(), - config, - burstTokens: config.burst?.capacity, - burstWindowStart: config.burst ? Date.now() : undefined, - queue: [], - scheduled: false, - }; - this.buckets.set(hostname, bucket); - } - return bucket; - } - - /** Top up tokens whose window has elapsed. */ - private refill(b: BucketState): void { - const now = Date.now(); - if (now - b.windowStart >= b.config.intervalMs) { - b.tokens = b.config.capacity; - b.windowStart = now; - } - if (b.config.burst && b.burstWindowStart != null) { - if (now - b.burstWindowStart >= b.config.burst.intervalMs) { - b.burstTokens = b.config.burst.capacity; - b.burstWindowStart = now; - } - } - } - - private tryConsume(b: BucketState): boolean { - if (b.tokens <= 0) return false; - if (b.config.burst && (b.burstTokens ?? 0) <= 0) return false; - b.tokens -= 1; - if (b.config.burst) b.burstTokens = (b.burstTokens ?? 0) - 1; - return true; - } - - /** Schedule a wake-up at the next time we'd hand out at least one token. */ - private schedulePump(b: BucketState): void { - if (b.scheduled) return; - b.scheduled = true; - const now = Date.now(); - const waitMain = Math.max(0, b.config.intervalMs - (now - b.windowStart)); - const waitBurst = - b.config.burst && b.burstWindowStart != null - ? Math.max(0, b.config.burst.intervalMs - (now - b.burstWindowStart)) - : 0; - const wait = Math.max(1, Math.min(waitMain || 1, waitBurst || waitMain || 1)); - setTimeout(() => this.pump(b), wait).unref?.(); - } - - /** Drain as many waiters as the refilled bucket can satisfy. */ - private pump(b: BucketState): void { - b.scheduled = false; - this.refill(b); - while (b.queue.length > 0 && this.tryConsume(b)) { - const entry = b.queue.shift()!; - entry.resolve(); - } - if (b.queue.length > 0) this.schedulePump(b); - } -} - -function abortError(signal: AbortSignal): Error { - if (signal.reason instanceof Error) return signal.reason; - const e = new Error('Aborted'); - e.name = 'AbortError'; - return e; -} - -/** - * Default policies for catalogue + mapping APIs we ship support for. - * Stream CDNs are deliberately *not* listed — they have their own pacing - * needs that the provider/extractor knows better than us. - */ -export const DEFAULT_RATE_LIMITS: PerHostRateLimits = { - 'graphql.anilist.co': { capacity: 85, intervalMs: 60_000 }, - 'api.jikan.moe': { - capacity: 55, - intervalMs: 60_000, - burst: { capacity: 3, intervalMs: 1_000 }, - }, - 'kitsu.io': { capacity: 100, intervalMs: 60_000 }, - 'api.malsync.moe': { capacity: 30, intervalMs: 60_000 }, - 'api.anify.tv': { capacity: 30, intervalMs: 60_000 }, - 'arm.haglund.dev': { capacity: 60, intervalMs: 60_000 }, -}; +export * from '../internal/rateLimiter.js'; diff --git a/src/transport/retry.ts b/src/transport/retry.ts index 06f0dca..611fc6d 100644 --- a/src/transport/retry.ts +++ b/src/transport/retry.ts @@ -1,164 +1 @@ -/** - * Generic retry-with-backoff helper. - * - * Wraps an async operation in an exponential-backoff loop, honoring `429` - * Retry-After hints when present. Used by `HttpClient.request` to recover - * from transient upstream errors without the caller writing the loop. - * - * Treated as retryable: - * - Network errors (`TypeError: fetch failed`, ECONNRESET, ETIMEDOUT, …). - * - Status codes in `retryStatuses` (default 408, 425, 429, 500, 502, 503, 504). - * - * Aborted signals short-circuit without retrying. - */ - -export interface RetryConfig { - /** Maximum number of attempts (including the first). Default 3. */ - maxAttempts?: number; - /** Initial backoff in ms. Default 250. */ - initialDelayMs?: number; - /** Cap on per-attempt backoff in ms. Default 8_000. */ - maxDelayMs?: number; - /** Exponential factor; default 2. */ - factor?: number; - /** Jitter as a 0..1 fraction added to each delay. Default 0.25. */ - jitter?: number; - /** Status codes that signal "try again". */ - retryStatuses?: number[]; - /** Observer hook for each retry. */ - onRetry?: (info: { attempt: number; reason: string; delayMs: number }) => void; - /** Custom predicate; combined OR-style with the status/error defaults. */ - isRetryableError?: (err: unknown) => boolean; -} - -export const DEFAULT_RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504]; - -/** Used internally so the loop can read `Retry-After` off a successful-but-throttled response. */ -export class HttpRetryableError extends Error { - public readonly status: number; - public readonly retryAfterMs?: number; - constructor(status: number, retryAfterMs?: number) { - super(`HTTP ${status}`); - this.status = status; - this.retryAfterMs = retryAfterMs; - this.name = 'HttpRetryableError'; - } -} - -export async function withRetry( - fn: (attempt: number) => Promise, - config: RetryConfig = {}, - signal?: AbortSignal, -): Promise { - const maxAttempts = config.maxAttempts ?? 3; - const initial = config.initialDelayMs ?? 250; - const max = config.maxDelayMs ?? 8_000; - const factor = config.factor ?? 2; - const jitter = clamp01(config.jitter ?? 0.25); - - let attempt = 0; - let lastErr: unknown; - while (attempt < maxAttempts) { - if (signal?.aborted) throw abortError(signal); - attempt += 1; - try { - return await fn(attempt); - } catch (err) { - lastErr = err; - if (signal?.aborted) throw abortError(signal); - if (!isRetryable(err, config)) throw err; - if (attempt >= maxAttempts) throw err; - - const hinted = err instanceof HttpRetryableError ? err.retryAfterMs : undefined; - const expBackoff = Math.min(max, initial * factor ** (attempt - 1)); - const noise = jitter > 0 ? expBackoff * jitter * Math.random() : 0; - const delayMs = Math.max(0, hinted ?? expBackoff + noise); - - config.onRetry?.({ - attempt, - reason: err instanceof Error ? err.message : String(err), - delayMs, - }); - await sleep(delayMs, signal); - } - } - throw lastErr; -} - -function isRetryable(err: unknown, config: RetryConfig): boolean { - if (config.isRetryableError?.(err)) return true; - if (err instanceof HttpRetryableError) { - return (config.retryStatuses ?? DEFAULT_RETRY_STATUSES).includes(err.status); - } - // Network-level errors. The shape varies across Node versions/runtimes — - // matching on name/message/code covers the common cases. - if (err instanceof Error) { - const name = err.name; - if (name === 'AbortError') return false; // explicit aborts are not retryable - if (name === 'TypeError' && /fetch failed|network/i.test(err.message)) return true; - if ( - 'code' in err && - typeof (err as { code?: unknown }).code === 'string' && - [ - 'ECONNRESET', - 'ECONNREFUSED', - 'ETIMEDOUT', - 'EAI_AGAIN', - 'EPIPE', - 'EHOSTUNREACH', - 'ENETUNREACH', - 'UND_ERR_SOCKET', - ].includes((err as { code?: string }).code!) - ) { - return true; - } - } - return false; -} - -/** Parse `Retry-After` (seconds or HTTP date) to milliseconds. */ -export function parseRetryAfter(value: string | null): number | undefined { - if (!value) return undefined; - const seconds = Number(value); - if (!Number.isNaN(seconds)) return seconds * 1000; - const date = Date.parse(value); - if (!Number.isNaN(date)) { - const ms = date - Date.now(); - return ms > 0 ? ms : 0; - } - return undefined; -} - -function sleep(ms: number, signal?: AbortSignal): Promise { - if (ms <= 0) return Promise.resolve(); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - resolve(); - }, ms); - timer.unref?.(); - const onAbort = () => { - cleanup(); - reject(abortError(signal!)); - }; - const cleanup = () => { - clearTimeout(timer); - signal?.removeEventListener('abort', onAbort); - }; - if (signal) { - if (signal.aborted) return onAbort(); - signal.addEventListener('abort', onAbort, { once: true }); - } - }); -} - -function clamp01(n: number): number { - return Math.max(0, Math.min(1, n)); -} - -function abortError(signal: AbortSignal): Error { - if (signal.reason instanceof Error) return signal.reason; - const e = new Error('Aborted'); - e.name = 'AbortError'; - return e; -} +export * from '../internal/retry.js'; diff --git a/src/transport/transport.ts b/src/transport/transport.ts index f797843..12fb73c 100644 --- a/src/transport/transport.ts +++ b/src/transport/transport.ts @@ -1,196 +1 @@ -/** - * Pluggable transport interface for `HttpClient`. - * - * The default {@link FetchTransport} is `fetch` with a curl fallback for - * Node — but the contract is just `(url, init) → Promise` so - * consumers can substitute anything they like (a custom Undici dispatcher, - * a Cloudflare-bypass service, an in-process test transport). - * - * Keeping the curl fallback behind this interface lets it be swapped out - * cleanly when it's not wanted — e.g. on Workers / Deno where `child_process` - * isn't available, or in tests that want to assert deterministic transport - * behaviour. - */ -export interface HttpTransport { - /** - * Perform one HTTP request. Implementations must: - * - honour `init.signal` (cancellation) - * - apply `init.headers` literally - * - return a real `Response` (or compatible shape) regardless of HTTP status - */ - fetch(url: string, init: RequestInit): Promise; -} - -/** - * Default browser-style transport — wraps the platform `fetch`. No fallback, - * no curl. Used in browsers and in Node when the caller opts out of the - * curl fallback via `HttpClientConfig.transport`. - */ -export class FetchTransport implements HttpTransport { - fetch(url: string, init: RequestInit): Promise { - return fetch(url, init); - } -} - -/** - * Fetch-with-curl-fallback transport. Tries `fetch` first; on network - * error (timeout, TLS quirk, anti-bot rejection) falls back to spawning - * `curl` via `child_process` and synthesising a `Response`-shaped object - * from its output. - * - * Only available in Node — `child_process` isn't usable in the browser, - * Workers, or Deno's sandboxed runtimes. The fallback no-ops in those - * environments and the original `fetch` error propagates. - * - * Per-instance cookie jar (`cookieFile`) is reused across calls so a site - * that sets a cookie on call 1 carries it on call 2. - */ -export class CurlFallbackTransport implements HttpTransport { - private cookieFile?: string; - private readonly timeoutMs: number; - - constructor(options: { timeoutMs?: number } = {}) { - this.timeoutMs = options.timeoutMs ?? 10_000; - } - - async fetch(url: string, init: RequestInit): Promise { - try { - return await fetch(url, init); - } catch (err: any) { - // Explicit aborts must propagate immediately. - if (init.signal?.aborted || (err?.name === 'AbortError' && init.signal)) { - throw err; - } - // Only attempt curl in Node. - if (typeof process === 'undefined' || !process.versions?.node) { - throw err; - } - try { - return await this.curlFetch(url, init); - } catch { - throw err; - } - } - } - - private async curlFetch(targetUrl: string, options: RequestInit): Promise { - const cp = await import('child_process'); - const execSync = cp.execSync; - - if (!this.cookieFile) { - try { - const os = await import('os'); - const path = await import('path'); - this.cookieFile = path.join( - os.tmpdir(), - `ani-sdk-cookie-${Math.random().toString(36).substring(2)}.txt`, - ); - } catch { - this.cookieFile = `/tmp/ani-sdk-cookie-${Math.random().toString(36).substring(2)}.txt`; - } - } - - const method = options.method || 'GET'; - const headers: Record = {}; - if (options.headers instanceof Headers) { - options.headers.forEach((v, k) => { - headers[k] = v; - }); - } else if (Array.isArray(options.headers)) { - for (const [k, v] of options.headers) headers[k] = v; - } else if (options.headers) { - Object.assign(headers, options.headers); - } - if (options.body instanceof URLSearchParams && !headers['Content-Type']) { - headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8'; - } - - let headerArgs = ''; - for (const [key, val] of Object.entries(headers)) { - headerArgs += ` -H ${JSON.stringify(`${key}: ${val}`)}`; - } - - let bodyArg = ''; - if (options.body) { - let bodyStr = ''; - if (typeof options.body === 'string') { - bodyStr = options.body; - } else if (options.body instanceof URLSearchParams) { - bodyStr = options.body.toString(); - } else { - bodyStr = JSON.stringify(options.body); - } - bodyArg = ` -d ${JSON.stringify(bodyStr)}`; - } - - const methodArg = method !== 'GET' && method !== 'POST' ? ` -X ${method}` : ''; - const cookieArg = ` -c ${JSON.stringify(this.cookieFile)} -b ${JSON.stringify(this.cookieFile)}`; - const cmd = `curl -sL --max-time ${Math.ceil(this.timeoutMs / 1000)}${methodArg}${headerArgs}${bodyArg}${cookieArg} -i ${JSON.stringify(targetUrl)}`; - const output = execSync(cmd, { maxBuffer: 10 * 1024 * 1024 }); - return parseCurlResponse(output.toString('binary'), targetUrl); - } -} - -/** - * Parse curl's `-i` (include headers) response into something - * `Response`-shaped. Handles 1xx/redirect chains by keeping only the - * last HTTP block. - */ -function parseCurlResponse(raw: string, targetUrl: string): Response { - const parts = raw.split('\r\n\r\n'); - let headerSection = ''; - let body = ''; - for (let i = 0; i < parts.length; i++) { - if (parts[i].startsWith('HTTP/')) { - headerSection = parts[i]; - body = parts.slice(i + 1).join('\r\n\r\n'); - } - } - const headerLines = headerSection.split('\r\n'); - const statusLine = headerLines[0]; - const m = statusLine.match(/HTTP\/\d+(\.\d+)?\s+(\d+)/); - const status = m ? parseInt(m[2], 10) : 200; - - const responseHeaders = new Headers(); - for (let i = 1; i < headerLines.length; i++) { - const line = headerLines[i]; - const idx = line.indexOf(':'); - if (idx !== -1) { - responseHeaders.append(line.substring(0, idx).trim(), line.substring(idx + 1).trim()); - } - } - - // Follow Location across the redirect chain for `Response.url`. - let finalUrl = targetUrl; - for (const part of parts) { - const lines = part.split('\r\n'); - if (lines[0].startsWith('HTTP/')) { - for (const line of lines) { - const idx = line.indexOf(':'); - if (idx === -1) continue; - const key = line.substring(0, idx).trim().toLowerCase(); - if (key !== 'location') continue; - const val = line.substring(idx + 1).trim(); - try { - finalUrl = val.startsWith('http') ? val : new URL(val, finalUrl).toString(); - } catch { - /* leave finalUrl as-is */ - } - } - } - } - - return { - status, - statusText: 'OK', - ok: status >= 200 && status < 300, - headers: responseHeaders, - url: finalUrl, - text: async () => body, - json: async () => JSON.parse(body), - arrayBuffer: async () => { - const buf = Buffer.from(body, 'binary'); - return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); - }, - } as unknown as Response; -} +export * from '../internal/transport.js'; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..7569c26 --- /dev/null +++ b/src/types.ts @@ -0,0 +1 @@ +// Phase 2: Media, Episode, Chapter, Stream, Pages, List, SourceInfo, Score diff --git a/src/utils/urn.ts b/src/utils/urn.ts index c3e026c..fcad213 100644 --- a/src/utils/urn.ts +++ b/src/utils/urn.ts @@ -1,129 +1 @@ -/** - * Unified Resource Name (URN) helpers. - * - * Every ID emitted by an SDK provider is prefixed with the provider's `id` - * followed by a single colon. The first colon is the separator — the raw ID - * that follows is treated as opaque and may itself contain colons, slashes, - * or other characters. - * - * Examples: - * `allmanga:5jzpRTJWnubrgHm5G` (media URN) - * `allmanga:5jzpRTJWnubrgHm5G/1` (content unit URN) - * `animeparadise:abc:xyz` (raw ID itself contains a colon) - * `anilist:21` (meta provider URN) - * - * Unifying ID space means callers can route any URN to the right provider - * without out-of-band knowledge of which provider it came from. - */ - -import type { Urn } from '../types/index.js'; - -/** True when the string looks like `providerId:rawId` for the given provider. */ -export function isUrn(value: string, providerId?: string): boolean { - const sep = value.indexOf(':'); - if (sep <= 0) return false; - if (providerId == null) return true; - return value.slice(0, sep) === providerId; -} - -/** Build a URN. The raw ID is taken as-is — no escaping is applied. */ -export function buildUrn(providerId: string, rawId: string): Urn { - if (!providerId) throw new Error('buildUrn: providerId is required'); - if (rawId == null) throw new Error('buildUrn: rawId is required'); - return `${providerId}:${rawId}`; -} - -/** - * Parse a URN into its provider and raw-ID parts. If the input has no colon, - * `providerId` is the empty string and `rawId` is the original input — this - * lets callers be liberal about accepting legacy bare IDs. - */ -export function parseUrn(urn: string): { providerId: string; rawId: string } { - const sep = urn.indexOf(':'); - if (sep < 0) return { providerId: '', rawId: urn }; - return { providerId: urn.slice(0, sep), rawId: urn.slice(sep + 1) }; -} - -/** - * Strip the URN prefix when it matches `providerId`. If the input has no - * prefix or a different prefix, it is returned unchanged — this is what lets - * providers accept both URN and legacy bare IDs. - */ -export function unwrapUrn(providerId: string, urn: string): string { - const sep = urn.indexOf(':'); - if (sep < 0) return urn; - const prefix = urn.slice(0, sep); - if (prefix !== providerId) return urn; - return urn.slice(sep + 1); -} - -/** - * Strict version of {@link unwrapUrn} — throws if the URN doesn't belong - * to `providerId`. Use this when routing decisions depend on the prefix - * being correct (e.g. before dispatching a `meta:anilist:21` to a content - * provider that wouldn't know what to do with it). - */ -export function strictUnwrapUrn(providerId: string, urn: string): string { - const sep = urn.indexOf(':'); - if (sep < 0) { - throw new Error(`strictUnwrapUrn: bare ID "${urn}" rejected (expected "${providerId}:…")`); - } - const prefix = urn.slice(0, sep); - if (prefix !== providerId) { - throw new Error( - `strictUnwrapUrn: prefix "${prefix}" does not match "${providerId}" for URN "${urn}"`, - ); - } - return urn.slice(sep + 1); -} - -/** - * Typed catalogue URN helpers. - * - * MAL and Kitsu IDs aren't globally unique — a single integer can belong to - * either an anime or a manga. We encode the catalogue type as the second - * segment so the URN is unambiguous: - * - * `mal:anime:21` `mal:manga:13` `kitsu:anime:11013` - * - * This lets routing logic (and the meta-provider's `fetchMediaInfo`) pick - * the right endpoint without falling back on a "try anime first, 404, try - * manga" heuristic. - * - * The first colon still separates `providerId`; the *second* colon is - * conventional only when the provider opts in. AniList (single ID - * namespace) doesn't use it. - */ -export type CatalogueKind = 'anime' | 'manga'; - -export function buildTypedUrn( - providerId: string, - kind: CatalogueKind, - rawId: string | number, -): Urn { - return `${providerId}:${kind}:${rawId}`; -} - -/** - * Parse a typed URN. Returns `{kind, rawId}` when the second segment is - * `"anime"` or `"manga"`, otherwise treats the whole post-prefix string as - * a bare raw ID with `kind: undefined`. Callers can fall through to the - * untyped path when the kind is missing. - */ -export function parseTypedUrn( - providerId: string, - urn: string, -): { kind?: CatalogueKind; rawId: string } { - const sep = urn.indexOf(':'); - if (sep < 0) return { rawId: urn }; - const prefix = urn.slice(0, sep); - const rest = urn.slice(sep + 1); - if (prefix !== providerId) return { rawId: urn }; - const sep2 = rest.indexOf(':'); - if (sep2 < 0) return { rawId: rest }; - const candidate = rest.slice(0, sep2); - if (candidate === 'anime' || candidate === 'manga') { - return { kind: candidate, rawId: rest.slice(sep2 + 1) }; - } - return { rawId: rest }; -} +export * from '../internal/id.js'; From d89061bc08172457e8eacfdb103be567cae027de Mon Sep 17 00:00:00 2001 From: HEXXT Date: Thu, 18 Jun 2026 16:01:04 +0100 Subject: [PATCH 03/19] =?UTF-8?q?feat(dom):=20Phase=201=20=E2=80=94=20bund?= =?UTF-8?q?led=20DOM=20parser=20(already=20implemented)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit linkedom is already in dependencies. dom.ts already auto-registers it via the top-level globalThis.DOMParser assignment on module import, guarded by a typeof check so it skips browser/jsdom environments. No E2E setup shim existed to delete. The existing dom.test.ts unit test verifies that BrowserDomParser works in Node without any manual setup. Phase 1 done: consumers never need to touch globalThis.DOMParser. From db83c04614889295e3f49bb783972f1598964574 Mon Sep 17 00:00:00 2001 From: HEXXT Date: Thu, 18 Jun 2026 16:02:19 +0100 Subject: [PATCH 04/19] =?UTF-8?q?feat(types):=20Phase=202=20=E2=80=94=20va?= =?UTF-8?q?lue=20types,=20AniError,=20SdkOptions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add src/types.ts: Media, Episode, Chapter, Stream, Pages, List, SourceInfo, MediaTitle, MediaCover, Subtitle, Score — all plain POJOs. Add src/errors.ts: AniError extends Error with AniErrorCode const enum. Add src/config.ts: SdkOptions type + resolveOptions() with defaults. Add tests/types.test.ts: JSON round-trips, error code branching, default option merging (9 tests, all pass). tsc --noEmit clean. --- src/config.ts | 35 ++++++++++++++- src/errors.ts | 34 +++++++++++++- src/types.ts | 105 +++++++++++++++++++++++++++++++++++++++++++- tests/types.test.ts | 95 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 266 insertions(+), 3 deletions(-) create mode 100644 tests/types.test.ts diff --git a/src/config.ts b/src/config.ts index 6e9ee55..4b2020e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1 +1,34 @@ -// Phase 2: SdkOptions + resolveOptions() +export interface SdkOptions { + sources?: string[]; + disabled?: string[]; + http?: { + timeoutMs?: number; + retries?: number; + userAgent?: string; + }; + proxy?: { + signSecret?: string; + allowedHosts?: string[]; + }; + cache?: { + get(k: string): unknown; + set(k: string, v: unknown): void; + }; + ratelimit?: Record; +} + +const DEFAULTS: Required> = { + http: { + timeoutMs: 30000, + retries: 3, + }, +}; + +export function resolveOptions( + opts?: SdkOptions, +): SdkOptions & { http: NonNullable } { + return { + ...opts, + http: { ...DEFAULTS.http, ...opts?.http }, + }; +} diff --git a/src/errors.ts b/src/errors.ts index 691623e..69592db 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1 +1,33 @@ -// Phase 2: AniError + AniErrorCode +export const AniErrorCode = { + SourceUnavailable: 'SourceUnavailable', + NoStream: 'NoStream', + RegionBlocked: 'RegionBlocked', + RateLimited: 'RateLimited', + NotFound: 'NotFound', + Cancelled: 'Cancelled', + BadId: 'BadId', +} as const; + +export type AniErrorCode = (typeof AniErrorCode)[keyof typeof AniErrorCode]; + +export class AniError extends Error { + readonly code: AniErrorCode; + readonly source?: string; + readonly retryable: boolean; + readonly cause?: unknown; + + constructor(opts: { + code: AniErrorCode; + message: string; + source?: string; + retryable?: boolean; + cause?: unknown; + }) { + super(opts.message); + this.name = 'AniError'; + this.code = opts.code; + this.source = opts.source; + this.retryable = opts.retryable ?? false; + this.cause = opts.cause; + } +} diff --git a/src/types.ts b/src/types.ts index 7569c26..39b6a2f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1 +1,104 @@ -// Phase 2: Media, Episode, Chapter, Stream, Pages, List, SourceInfo, Score +export interface MediaTitle { + preferred: string; + english?: string; + romaji?: string; + native?: string; +} + +export interface MediaCover { + url: string; + color?: string; +} + +export interface Score { + value: number; + scale: number; +} + +export interface Media { + id: string; + kind: 'anime' | 'manga'; + title: MediaTitle; + cover?: MediaCover; + banner?: string; + score?: Score; + year?: number; + season?: 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL'; + status?: 'FINISHED' | 'RELEASING' | 'NOT_YET_RELEASED' | 'CANCELLED' | 'HIATUS'; + format?: 'TV' | 'MOVIE' | 'OVA' | 'ONA' | 'SPECIAL' | 'MANGA' | 'NOVEL'; + episodeCount?: number; + chapterCount?: number; + description?: string; + catalogues: string[]; + playbackSources: string[]; + mappings: { + anilist?: number; + mal?: number; + kitsu?: number; + sources?: Record; + }; +} + +export interface Episode { + id: string; + mediaId: string; + number: number; + title?: string; + thumbnail?: string; + airDate?: string; + filler?: boolean; + recap?: boolean; + languages: ('sub' | 'dub' | 'raw')[]; + qualities: ('1080p' | '720p' | '480p' | '360p' | 'auto')[]; + source: string; +} + +export interface Chapter { + id: string; + mediaId: string; + number: number; + title?: string; + source: string; +} + +export interface Subtitle { + url: string; + language: string; + label: string; + format: 'vtt' | 'srt' | 'ass'; +} + +export interface Stream { + url: string; + origin: { host: string; url: string; proxied: boolean }; + isHls: boolean; + qualities: { label: '1080p' | '720p' | '480p' | '360p' | 'auto'; url: string }[]; + language: 'sub' | 'dub' | 'raw'; + subtitles: Subtitle[]; + headers?: Record; + adjacent: { + prev?: { id: string; number: number }; + next?: { id: string; number: number }; + }; +} + +export interface Pages { + pages: { url: string; origin: { host: string }; width?: number; height?: number }[]; + adjacent: { + prev?: { id: string; number: number }; + next?: { id: string; number: number }; + }; +} + +export interface List { + items: T[]; + nextCursor?: string; + total?: number; +} + +export interface SourceInfo { + id: string; + status: 'available' | 'incompatible' | 'error'; + episodeCount?: number; + successRate?: number; +} diff --git a/tests/types.test.ts b/tests/types.test.ts new file mode 100644 index 0000000..e53f513 --- /dev/null +++ b/tests/types.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest'; +import { AniError, AniErrorCode } from '../src/errors.js'; +import { resolveOptions } from '../src/config.js'; +import type { Media, Episode, Chapter, Stream, List, Score } from '../src/types.js'; + +describe('AniError', () => { + it('round-trips through JSON', () => { + const err = new AniError({ code: AniErrorCode.NoStream, message: 'no stream' }); + expect(err.code).toBe('NoStream'); + expect(err.retryable).toBe(false); + expect(err.name).toBe('AniError'); + expect(err instanceof AniError).toBe(true); + expect(err instanceof Error).toBe(true); + }); + + it('branches on error codes correctly', () => { + const codes: AniErrorCode[] = []; + const err = new AniError({ + code: AniErrorCode.RateLimited, + message: 'rate limited', + retryable: true, + }); + switch (err.code) { + case AniErrorCode.RateLimited: + codes.push(err.code); + break; + default: + codes.push('other' as AniErrorCode); + } + expect(codes).toEqual([AniErrorCode.RateLimited]); + expect(err.retryable).toBe(true); + }); + + it('carries source and cause', () => { + const cause = new Error('upstream'); + const err = new AniError({ + code: AniErrorCode.SourceUnavailable, + message: 'down', + source: 'allmanga', + cause, + }); + expect(err.source).toBe('allmanga'); + expect(err.cause).toBe(cause); + }); +}); + +describe('resolveOptions', () => { + it('applies defaults when no opts given', () => { + const opts = resolveOptions(); + expect(opts.http!.timeoutMs).toBe(30000); + expect(opts.http!.retries).toBe(3); + }); + + it('merges user overrides onto defaults', () => { + const opts = resolveOptions({ http: { timeoutMs: 5000 } }); + expect(opts.http!.timeoutMs).toBe(5000); + expect(opts.http!.retries).toBe(3); + }); +}); + +describe('value types are plain POJOs', () => { + it('Media round-trips through JSON', () => { + const m: Media = { + id: 'opaque-id', + kind: 'anime', + title: { preferred: 'Frieren', english: 'Frieren' }, + catalogues: ['anilist'], + playbackSources: [], + mappings: { anilist: 154587 }, + }; + expect(JSON.parse(JSON.stringify(m))).toEqual(m); + }); + + it('Episode round-trips through JSON', () => { + const ep: Episode = { + id: 'ep-id', + mediaId: 'media-id', + number: 1, + languages: ['sub'], + qualities: ['auto'], + source: 'allmanga', + }; + expect(JSON.parse(JSON.stringify(ep))).toEqual(ep); + }); + + it('Chapter round-trips through JSON', () => { + const ch: Chapter = { id: 'ch-id', mediaId: 'm-id', number: 1, source: 'mangadex' }; + expect(JSON.parse(JSON.stringify(ch))).toEqual(ch); + }); + + it('Score carries units', () => { + const s: Score = { value: 87, scale: 100 }; + expect(s.value / s.scale).toBeCloseTo(0.87); + }); +}); From 4ffb74fa1fa01ef7f590e1355499ce6a2dee6a3e Mon Sep 17 00:00:00 2001 From: HEXXT Date: Thu, 18 Jun 2026 16:03:31 +0100 Subject: [PATCH 05/19] =?UTF-8?q?feat(ids):=20Phase=203=20=E2=80=94=20opaq?= =?UTF-8?q?ue=20base64url=20ID=20encode/decode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add encodeId/decodeId to src/internal/id.ts. Plain base64url-encoded JSON tokens: { v:1, t:'media'|'episode'|'chapter', s:sourceId, r:rawId, m? }. decodeId throws AniError(BadId) on malformed input, missing required fields, or wrong version. Legacy URN helpers kept in the same file for existing providers (removed in Phase 9). tests/id.test.ts: 7 tests covering round-trip, mappings, malformed input, version check. tsc clean. 110 unit tests pass. --- src/internal/id.ts | 37 ++++++++++++++++++++++++++++ tests/id.test.ts | 61 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 tests/id.test.ts diff --git a/src/internal/id.ts b/src/internal/id.ts index c3e026c..32ca5d0 100644 --- a/src/internal/id.ts +++ b/src/internal/id.ts @@ -1,3 +1,40 @@ +import { AniError, AniErrorCode } from '../errors.js'; + +// ─── Opaque ID encode/decode ────────────────────────────────────────────────── +// +// Media/Episode/Chapter ids are base64url-encoded JSON tokens that carry the +// source lineage the SDK needs to dispatch follow-up calls. Format version 1: +// { v: 1, t: 'media'|'episode'|'chapter', s: sourceId, r: rawId, m?: mappings } + +export interface IdPayload { + v: 1; + t: 'media' | 'episode' | 'chapter'; + s: string; + r: string; + m?: Record; +} + +export function encodeId(payload: Omit): string { + const full: IdPayload = { v: 1, ...payload }; + return Buffer.from(JSON.stringify(full), 'utf8').toString('base64url'); +} + +export function decodeId(id: string): IdPayload { + try { + const json = Buffer.from(id, 'base64url').toString('utf8'); + const obj = JSON.parse(json) as IdPayload; + if (obj.v !== 1 || !obj.t || !obj.s || obj.r == null) { + throw new AniError({ code: AniErrorCode.BadId, message: `malformed id: ${id}` }); + } + return obj; + } catch (e) { + if (e instanceof AniError) throw e; + throw new AniError({ code: AniErrorCode.BadId, message: `malformed id: ${id}`, cause: e }); + } +} + +// ─── Legacy URN helpers (kept for existing providers) ───────────────────────── +// /** * Unified Resource Name (URN) helpers. * diff --git a/tests/id.test.ts b/tests/id.test.ts new file mode 100644 index 0000000..9fc66f9 --- /dev/null +++ b/tests/id.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { encodeId, decodeId } from '../src/internal/id.js'; +import { AniError, AniErrorCode } from '../src/errors.js'; + +describe('encodeId / decodeId', () => { + it('round-trips a media id', () => { + const id = encodeId({ t: 'media', s: 'allmanga', r: 'abc123' }); + const decoded = decodeId(id); + expect(decoded.v).toBe(1); + expect(decoded.t).toBe('media'); + expect(decoded.s).toBe('allmanga'); + expect(decoded.r).toBe('abc123'); + }); + + it('round-trips an episode id with mappings', () => { + const id = encodeId({ t: 'episode', s: 'megaplay', r: '21/5', m: { al: 21 } }); + const decoded = decodeId(id); + expect(decoded.t).toBe('episode'); + expect(decoded.r).toBe('21/5'); + expect(decoded.m).toEqual({ al: 21 }); + }); + + it('round-trips a chapter id', () => { + const id = encodeId({ t: 'chapter', s: 'mangadex', r: 'uuid-here' }); + const decoded = decodeId(id); + expect(decoded.t).toBe('chapter'); + expect(decoded.s).toBe('mangadex'); + }); + + it('version field is always 1', () => { + const id = encodeId({ t: 'media', s: 'x', r: 'y' }); + expect(decodeId(id).v).toBe(1); + }); + + it('malformed base64 throws BadId', () => { + expect(() => decodeId('!!!not-base64!!!')).toThrow(AniError); + try { + decodeId('!!!not-base64!!!'); + } catch (e) { + expect(e instanceof AniError).toBe(true); + expect((e as AniError).code).toBe(AniErrorCode.BadId); + } + }); + + it('missing required fields throws BadId', () => { + const bad = Buffer.from(JSON.stringify({ v: 1, s: 'x' }), 'utf8').toString('base64url'); + expect(() => decodeId(bad)).toThrow(AniError); + try { + decodeId(bad); + } catch (e) { + expect((e as AniError).code).toBe(AniErrorCode.BadId); + } + }); + + it('wrong version throws BadId', () => { + const bad = Buffer.from(JSON.stringify({ v: 99, t: 'media', s: 'x', r: 'y' }), 'utf8').toString( + 'base64url', + ); + expect(() => decodeId(bad)).toThrow(AniError); + }); +}); From 1a2cf3ccd29666b3a6b83c16f0545c76a11da572 Mon Sep 17 00:00:00 2001 From: HEXXT Date: Thu, 18 Jun 2026 16:08:27 +0100 Subject: [PATCH 06/19] =?UTF-8?q?feat(registry):=20Phase=204=20=E2=80=94?= =?UTF-8?q?=20Source=20interface,=20registry,=20health=20tracker,=20AniLis?= =?UTF-8?q?t=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add src/sources/base.ts: single Source interface (internal) with caps flags and optional capability methods — replaces BaseProvider+BaseMetadataProvider for new sources. Add src/health.ts: HealthTracker — rolling 20-call success/latency window per source. Synchronous snapshot(). Used to rank playback sources. Add src/registry.ts: Registry.register(), sourcesFor(), fanOutSearch() (AsyncIterable), mergeEpisodes(), rankPlaybackSources(). MappingClient invocations wired in Phase 6 when playback sources migrate. Add src/sources/anilist.ts: AnilistSource implements Source. Ports AniList GraphQL search/info/browse from AnilistMeta with new Media return type and opaque base64url IDs. Unit tests (5): registry fanout, sourcesFor filtering, health stats. Live E2E (4): search/info/browse all pass against graphql.anilist.co. --- src/health.ts | 71 +++++++++++++++- src/registry.ts | 95 +++++++++++++++++++++- src/sources/anilist.ts | 165 ++++++++++++++++++++++++++++++++++++++ src/sources/base.ts | 63 +++++++++++++++ tests/e2e/anilist.test.ts | 44 ++++++++++ tests/registry.test.ts | 97 ++++++++++++++++++++++ 6 files changed, 533 insertions(+), 2 deletions(-) create mode 100644 src/sources/anilist.ts create mode 100644 src/sources/base.ts create mode 100644 tests/e2e/anilist.test.ts create mode 100644 tests/registry.test.ts diff --git a/src/health.ts b/src/health.ts index 5a6a933..e779450 100644 --- a/src/health.ts +++ b/src/health.ts @@ -1 +1,70 @@ -// Phase 4: Rolling success/latency tracker per source +const WINDOW = 20; + +export interface SourceHealth { + id: string; + successRate: number; + avgLatencyMs: number; + calls: number; +} + +interface Entry { + ok: boolean; + ms: number; +} + +class SourceTracker { + private window: Entry[] = []; + private cursor = 0; + private total = 0; + + record(ok: boolean, ms: number): void { + if (this.window.length < WINDOW) { + this.window.push({ ok, ms }); + } else { + this.window[this.cursor % WINDOW] = { ok, ms }; + } + this.cursor++; + this.total++; + } + + snapshot(): { successRate: number; avgLatencyMs: number; calls: number } { + if (this.window.length === 0) return { successRate: 1, avgLatencyMs: 0, calls: 0 }; + const ok = this.window.filter((e) => e.ok).length; + const avg = this.window.reduce((s, e) => s + e.ms, 0) / this.window.length; + return { + successRate: ok / this.window.length, + avgLatencyMs: avg, + calls: this.total, + }; + } +} + +export class HealthTracker { + private trackers = new Map(); + + private tracker(id: string): SourceTracker { + let t = this.trackers.get(id); + if (!t) { + t = new SourceTracker(); + this.trackers.set(id, t); + } + return t; + } + + record(id: string, ok: boolean, ms: number): void { + this.tracker(id).record(ok, ms); + } + + snapshot(): SourceHealth[] { + const out: SourceHealth[] = []; + for (const [id, t] of this.trackers) { + out.push({ id, ...t.snapshot() }); + } + return out; + } + + get(id: string): SourceHealth { + const s = this.trackers.get(id)?.snapshot() ?? { successRate: 1, avgLatencyMs: 0, calls: 0 }; + return { id, ...s }; + } +} diff --git a/src/registry.ts b/src/registry.ts index 7d8faad..20ebcd0 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -1 +1,94 @@ -// Phase 4: Registry — sourcesFor, fanOutSearch, mergeEpisodes, rankPlaybackSources +import type { Source, SourceCallOpts } from './sources/base.js'; +import type { Media, Episode, List, SourceInfo } from './types.js'; +import { HealthTracker } from './health.js'; + +export class Registry { + private sources: Source[] = []; + private health = new HealthTracker(); + + register(...sources: Source[]): void { + this.sources.push(...sources); + } + + sourcesFor(kind: 'anime' | 'manga', cap: keyof Source['caps']): Source[] { + return this.sources.filter( + (s) => s.kinds.includes(kind) && (s.caps as Record)[cap], + ); + } + + async *fanOutSearch( + query: string, + kind: 'anime' | 'manga', + opts: SourceCallOpts, + ): AsyncIterable { + const sources = this.sourcesFor(kind, 'search'); + const pending = sources.map(async (src) => { + const t0 = Date.now(); + try { + const results = await src.search!(query, kind, opts); + this.health.record(src.id, true, Date.now() - t0); + return results; + } catch { + this.health.record(src.id, false, Date.now() - t0); + return [] as Media[]; + } + }); + + const settled = await Promise.all(pending); + for (const batch of settled) { + for (const item of batch) { + yield item; + } + } + } + + async mergeEpisodes( + media: Media, + opts: SourceCallOpts & { cursor?: string; limit?: number }, + ): Promise> { + const kind = media.kind; + const sources = this.sourcesFor(kind, 'episodes'); + if (sources.length === 0) return { items: [] }; + + const ranked = this.rankByHealth(sources); + for (const src of ranked) { + const mediaId = media.mappings.sources?.[src.id]; + if (!mediaId) continue; + const t0 = Date.now(); + try { + const result = await src.episodes!(mediaId, opts); + this.health.record(src.id, true, Date.now() - t0); + return result; + } catch { + this.health.record(src.id, false, Date.now() - t0); + } + } + return { items: [] }; + } + + async rankPlaybackSources(media: Media, opts: SourceCallOpts): Promise { + const kind = media.kind; + const sources = this.sourcesFor(kind, 'episodes'); + return sources.map((src) => { + const h = this.health.get(src.id); + const mediaId = media.mappings.sources?.[src.id]; + return { + id: src.id, + status: mediaId ? 'available' : 'incompatible', + successRate: h.successRate, + } satisfies SourceInfo; + }); + } + + getHealthTracker(): HealthTracker { + return this.health; + } + + private rankByHealth(sources: Source[]): Source[] { + return [...sources].sort((a, b) => { + const ha = this.health.get(a.id); + const hb = this.health.get(b.id); + return hb.successRate - ha.successRate; + }); + } +} diff --git a/src/sources/anilist.ts b/src/sources/anilist.ts new file mode 100644 index 0000000..31541d0 --- /dev/null +++ b/src/sources/anilist.ts @@ -0,0 +1,165 @@ +import { HttpClient } from '../internal/http.js'; +import { encodeId } from '../internal/id.js'; +import type { Media, List } from '../types.js'; +import type { Source, SourceCallOpts } from './base.js'; + +const ANILIST_API = 'https://graphql.anilist.co'; + +const SEARCH_FIELDS = /* GraphQL */ ` + id type format seasonYear startDate { year } + averageScore isAdult idMal + title { romaji english native userPreferred } + coverImage { extraLarge large medium color } +`; + +const INFO_FIELDS = /* GraphQL */ ` + id type format status episodes chapters duration season + seasonYear startDate { year month day } endDate { year month day } + averageScore isAdult idMal description synonyms genres + studios(isMain: true) { nodes { name } } + tags { name } + title { romaji english native userPreferred } + coverImage { extraLarge large medium color } + bannerImage +`; + +function toKind(type: string): 'anime' | 'manga' { + return type === 'MANGA' ? 'manga' : 'anime'; +} + +function toScore(v: unknown): { value: number; scale: number } | undefined { + if (typeof v !== 'number') return undefined; + return { value: v, scale: 100 }; +} + +function mapTitle(t: any): Media['title'] { + return { + preferred: t?.userPreferred ?? t?.romaji ?? t?.english ?? '', + english: t?.english ?? undefined, + romaji: t?.romaji ?? undefined, + native: t?.native ?? undefined, + }; +} + +function mapCover(c: any): Media['cover'] { + if (!c) return undefined; + const url = c.extraLarge ?? c.large ?? c.medium; + if (!url) return undefined; + return { url, color: c.color ?? undefined }; +} + +function formatDate(d: any): string | undefined { + if (!d?.year) return undefined; + const y = String(d.year).padStart(4, '0'); + if (!d.month) return y; + const m = String(d.month).padStart(2, '0'); + if (!d.day) return `${y}-${m}`; + return `${y}-${m}-${String(d.day).padStart(2, '0')}`; +} + +function mapNode(m: any, sourceId: string): Media { + const kind = toKind(m.type); + return { + id: encodeId({ t: 'media', s: sourceId, r: String(m.id), m: { al: m.id, mal: m.idMal } }), + kind, + title: mapTitle(m.title), + cover: mapCover(m.coverImage), + banner: m.bannerImage ?? undefined, + score: toScore(m.averageScore), + year: m.seasonYear ?? m.startDate?.year ?? undefined, + season: m.season ?? undefined, + status: m.status ?? undefined, + format: m.format ?? undefined, + episodeCount: m.episodes ?? undefined, + chapterCount: m.chapters ?? undefined, + description: m.description ?? undefined, + catalogues: [sourceId], + playbackSources: [], + mappings: { + anilist: m.id, + mal: m.idMal ?? undefined, + }, + }; +} + +export class AnilistSource implements Source { + readonly id = 'anilist'; + readonly kinds = ['anime', 'manga'] as const; + readonly caps = { search: true, info: true, browse: true } as const; + + private http: HttpClient; + private apiUrl: string; + + constructor(http: HttpClient, apiUrl = ANILIST_API) { + this.http = http; + this.apiUrl = apiUrl; + } + + async search(query: string, kind: 'anime' | 'manga', opts: SourceCallOpts): Promise { + const type = kind === 'manga' ? 'MANGA' : 'ANIME'; + const gql = `query($q:String,$type:MediaType,$perPage:Int){Page(perPage:$perPage){media(search:$q,type:$type,sort:SEARCH_MATCH){${SEARCH_FIELDS}}}}`; + const res = await this.http.post( + this.apiUrl, + { query: gql, variables: { q: query, type, perPage: 25 } }, + { signal: opts.signal }, + ); + if (res.status !== 200) throw new Error(`AniList search failed: ${res.status}`); + const json = (await res.json()) as any; + return ((json?.data?.Page?.media as any[]) ?? []).map((m) => mapNode(m, this.id)); + } + + async info(id: string, opts: SourceCallOpts): Promise { + const gql = `query($id:Int){Media(id:$id){${INFO_FIELDS}}}`; + const res = await this.http.post( + this.apiUrl, + { query: gql, variables: { id: Number(id) } }, + { signal: opts.signal }, + ); + if (res.status !== 200) throw new Error(`AniList info failed: ${res.status}`); + const json = (await res.json()) as any; + const m = json?.data?.Media; + if (!m) throw new Error(`AniList: no media for id ${id}`); + return mapNode(m, this.id); + } + + async browse( + opts: SourceCallOpts & { + list: 'trending' | 'popular' | 'seasonal' | 'top'; + kind: 'anime' | 'manga'; + page?: number; + perPage?: number; + season?: string; + year?: number; + }, + ): Promise> { + const type = opts.kind === 'manga' ? 'MANGA' : 'ANIME'; + const sortMap: Record = { + trending: 'TRENDING_DESC', + popular: 'POPULARITY_DESC', + seasonal: 'POPULARITY_DESC', + top: 'SCORE_DESC', + }; + const sort = sortMap[opts.list]; + const vars: Record = { + type, + sort, + page: opts.page ?? 1, + perPage: Math.min(opts.perPage ?? 20, 50), + }; + if (opts.list === 'seasonal') { + if (!opts.season || !opts.year) throw new Error('browse(seasonal): season and year required'); + vars.season = opts.season; + vars.seasonYear = opts.year; + } + const gql = `query($type:MediaType,$sort:[MediaSort],$page:Int,$perPage:Int,$season:MediaSeason,$seasonYear:Int){Page(page:$page,perPage:$perPage){media(type:$type,sort:$sort,season:$season,seasonYear:$seasonYear){${SEARCH_FIELDS}}}}`; + const res = await this.http.post( + this.apiUrl, + { query: gql, variables: vars }, + { signal: opts.signal }, + ); + if (res.status !== 200) throw new Error(`AniList browse failed: ${res.status}`); + const json = (await res.json()) as any; + const items = ((json?.data?.Page?.media as any[]) ?? []).map((m) => mapNode(m, this.id)); + return { items }; + } +} diff --git a/src/sources/base.ts b/src/sources/base.ts new file mode 100644 index 0000000..fa02379 --- /dev/null +++ b/src/sources/base.ts @@ -0,0 +1,63 @@ +import type { Media, Episode, Chapter, Stream, Pages, List } from '../types.js'; + +export interface SourceCallOpts { + signal?: AbortSignal; +} + +export interface SourceCaps { + search?: true; + info?: true; + episodes?: true; + chapters?: true; + stream?: true; + pages?: true; + browse?: true; + mapping?: true; +} + +export interface Source { + readonly id: string; + readonly kinds: readonly ('anime' | 'manga')[]; + readonly caps: SourceCaps; + + search?(query: string, kind: 'anime' | 'manga', opts: SourceCallOpts): Promise; + + info?(id: string, opts: SourceCallOpts): Promise; + + episodes?( + mediaId: string, + opts: SourceCallOpts & { cursor?: string; limit?: number }, + ): Promise>; + + chapters?( + mediaId: string, + opts: SourceCallOpts & { cursor?: string; limit?: number }, + ): Promise>; + + stream?( + episodeId: string, + opts: SourceCallOpts & { + language?: 'sub' | 'dub' | 'raw'; + quality?: string; + adjacency?: 'within-media' | 'walk-relations'; + }, + ): Promise; + + pages?(chapterId: string, opts: SourceCallOpts): Promise; + + browse?( + opts: SourceCallOpts & { + list: 'trending' | 'popular' | 'seasonal' | 'top'; + kind: 'anime' | 'manga'; + page?: number; + perPage?: number; + season?: string; + year?: number; + }, + ): Promise>; + + lookupByMapping?( + mappings: Record, + opts?: SourceCallOpts, + ): Promise; +} diff --git a/tests/e2e/anilist.test.ts b/tests/e2e/anilist.test.ts new file mode 100644 index 0000000..e598dc7 --- /dev/null +++ b/tests/e2e/anilist.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { HttpClient } from '../../src/internal/http.js'; +import { AnilistSource } from '../../src/sources/anilist.js'; +import { decodeId } from '../../src/internal/id.js'; + +describe('AnilistSource (live)', () => { + const http = new HttpClient({ timeoutMs: 20_000 }); + const source = new AnilistSource(http); + + it('search returns Media items with opaque IDs and correct fields', async () => { + const results = await source.search('frieren', 'anime', {}); + expect(results.length).toBeGreaterThan(0); + const first = results[0]; + expect(first.kind).toBe('anime'); + expect(first.title.preferred).toBeTruthy(); + expect(first.catalogues).toContain('anilist'); + expect(first.mappings.anilist).toBeTypeOf('number'); + + const decoded = decodeId(first.id); + expect(decoded.s).toBe('anilist'); + expect(decoded.t).toBe('media'); + }, 30000); + + it('info returns full Media for a known ID (Frieren = 154587)', async () => { + const media = await source.info('154587', {}); + expect(media.kind).toBe('anime'); + expect(media.title.preferred).toBeTruthy(); + expect(media.episodeCount).toBeGreaterThan(0); + expect(media.mappings.anilist).toBe(154587); + expect(media.score?.scale).toBe(100); + }, 30000); + + it('browse(trending) returns a list of Media', async () => { + const list = await source.browse({ list: 'trending', kind: 'anime' }); + expect(list.items.length).toBeGreaterThan(0); + expect(list.items[0].kind).toBe('anime'); + }, 30000); + + it('browse(seasonal) requires season and year', async () => { + await expect(source.browse({ list: 'seasonal', kind: 'anime' })).rejects.toThrow( + 'season and year required', + ); + }); +}); diff --git a/tests/registry.test.ts b/tests/registry.test.ts new file mode 100644 index 0000000..c7f5070 --- /dev/null +++ b/tests/registry.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import { Registry } from '../src/registry.js'; +import type { Source } from '../src/sources/base.js'; +import type { Media } from '../src/types.js'; + +function makeStubSource(id: string, kinds: ('anime' | 'manga')[], results: Media[]): Source { + return { + id, + kinds, + caps: { search: true }, + async search(_q, _kind, _opts) { + return results; + }, + }; +} + +const FAKE_MEDIA: Media = { + id: 'test-id', + kind: 'anime', + title: { preferred: 'Test Show' }, + catalogues: ['stub'], + playbackSources: [], + mappings: {}, +}; + +describe('Registry', () => { + it('sourcesFor filters by kind and cap', () => { + const reg = new Registry(); + const a = makeStubSource('a', ['anime'], []); + const b = makeStubSource('b', ['manga'], []); + reg.register(a, b); + + expect(reg.sourcesFor('anime', 'search')).toEqual([a]); + expect(reg.sourcesFor('manga', 'search')).toEqual([b]); + expect(reg.sourcesFor('anime', 'info')).toEqual([]); + }); + + it('fanOutSearch yields results from all matching sources', async () => { + const reg = new Registry(); + const m1 = { ...FAKE_MEDIA, id: 'id1' }; + const m2 = { ...FAKE_MEDIA, id: 'id2', title: { preferred: 'Other' } }; + reg.register(makeStubSource('src1', ['anime'], [m1]), makeStubSource('src2', ['anime'], [m2])); + + const results: Media[] = []; + for await (const item of reg.fanOutSearch('test', 'anime', {})) { + results.push(item); + } + expect(results).toHaveLength(2); + expect(results.map((r) => r.id)).toContain('id1'); + expect(results.map((r) => r.id)).toContain('id2'); + }); + + it('fanOutSearch ignores sources without search cap', async () => { + const reg = new Registry(); + const noSearchSource: Source = { + id: 'no-search', + kinds: ['anime'], + caps: { info: true }, + }; + reg.register(makeStubSource('with-search', ['anime'], [FAKE_MEDIA]), noSearchSource); + + const results: Media[] = []; + for await (const item of reg.fanOutSearch('x', 'anime', {})) { + results.push(item); + } + expect(results).toHaveLength(1); + }); + + it('rankPlaybackSources marks sources without media mapping as incompatible', async () => { + const reg = new Registry(); + const epSource: Source = { + id: 'ep-src', + kinds: ['anime'], + caps: { episodes: true }, + async episodes() { + return { items: [] }; + }, + }; + reg.register(epSource); + + const media: Media = { ...FAKE_MEDIA, mappings: { sources: {} } }; + const ranked = await reg.rankPlaybackSources(media, {}); + expect(ranked).toHaveLength(1); + expect(ranked[0].status).toBe('incompatible'); + }); + + it('HealthTracker records and returns stats', () => { + const reg = new Registry(); + const ht = reg.getHealthTracker(); + ht.record('src1', true, 100); + ht.record('src1', false, 200); + ht.record('src1', true, 150); + const h = ht.get('src1'); + expect(h.calls).toBe(3); + expect(h.successRate).toBeCloseTo(2 / 3); + }); +}); From 844e716cf38efc2a17b972341bc889f746177647 Mon Sep 17 00:00:00 2001 From: HEXXT Date: Thu, 18 Jun 2026 16:10:30 +0100 Subject: [PATCH 07/19] =?UTF-8?q?feat(progressive):=20Phase=205=20?= =?UTF-8?q?=E2=80=94=20ProgressiveResult=20+=20AbortSignal=20plumbing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add src/progressive.ts: createProgressiveResult() returns an object that is both AsyncIterable (results as they arrive) and PromiseLike (collect all). AbortSignal.any() composes caller signal with internal cancel(). Aborted result rejects with AniError(Cancelled). Update src/registry.ts: fanOutSearch() now returns ProgressiveResult via createProgressiveResult — each source is a producer that forwards its AbortSignal. AbortSignal is passed through every fanOutSearch call. tests/progressive.test.ts: 5 tests — await collect, async iteration, cancel via AbortSignal, cancel() stops iteration, empty producers. 120 unit tests pass. tsc clean. --- src/progressive.ts | 92 ++++++++++++++++++++++++++++++++++++++- src/registry.ts | 37 ++++++++-------- tests/progressive.test.ts | 79 +++++++++++++++++++++++++++++++++ 3 files changed, 187 insertions(+), 21 deletions(-) create mode 100644 tests/progressive.test.ts diff --git a/src/progressive.ts b/src/progressive.ts index c0e7c80..d86cf29 100644 --- a/src/progressive.ts +++ b/src/progressive.ts @@ -1 +1,91 @@ -// Phase 5: ProgressiveResult — AsyncIterable & PromiseLike +import { AniError, AniErrorCode } from './errors.js'; + +export interface ProgressiveResult extends AsyncIterable, PromiseLike { + cancel(): void; +} + +type Producer = (push: (item: T) => void, signal: AbortSignal) => Promise; + +export function createProgressiveResult( + producers: Producer[], + signal?: AbortSignal, +): ProgressiveResult { + const ac = new AbortController(); + const combined = signal ? AbortSignal.any([signal, ac.signal]) : ac.signal; + + const queue: T[] = []; + let done = false; + let producerError: unknown; + const waiters: Array<() => void> = []; + + function push(item: T): void { + if (combined.aborted) return; + queue.push(item); + waiters.shift()?.(); + } + + function wake(): void { + waiters.shift()?.(); + } + + const allDone = Promise.all( + producers.map((p) => + p(push, combined).catch((e) => { + if (!combined.aborted) producerError = e; + }), + ), + ).finally(() => { + done = true; + wake(); + }); + + const collectAll = (): Promise => + allDone.then(() => { + if (combined.aborted && signal?.aborted) { + return Promise.reject( + new AniError({ code: AniErrorCode.Cancelled, message: 'Search cancelled' }), + ); + } + if (producerError) return Promise.reject(producerError); + return [...queue]; + }); + + const result: ProgressiveResult = { + cancel() { + ac.abort(); + wake(); + }, + + then( + onfulfilled?: ((v: T[]) => R1 | PromiseLike) | null, + onrejected?: ((e: unknown) => R2 | PromiseLike) | null, + ): Promise { + return collectAll().then(onfulfilled, onrejected); + }, + + [Symbol.asyncIterator](): AsyncIterator { + let index = 0; + return { + async next(): Promise> { + while (true) { + if (index < queue.length) { + return { value: queue[index++], done: false }; + } + if (done || combined.aborted) { + if (combined.aborted && signal?.aborted) { + throw new AniError({ + code: AniErrorCode.Cancelled, + message: 'Search cancelled', + }); + } + return { value: undefined as T, done: true }; + } + await new Promise((r) => waiters.push(r)); + } + }, + }; + }, + }; + + return result; +} diff --git a/src/registry.ts b/src/registry.ts index 20ebcd0..aa0b521 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -1,6 +1,7 @@ import type { Source, SourceCallOpts } from './sources/base.js'; import type { Media, Episode, List, SourceInfo } from './types.js'; import { HealthTracker } from './health.js'; +import { createProgressiveResult, type ProgressiveResult } from './progressive.js'; export class Registry { private sources: Source[] = []; @@ -16,30 +17,26 @@ export class Registry { ); } - async *fanOutSearch( + fanOutSearch( query: string, kind: 'anime' | 'manga', opts: SourceCallOpts, - ): AsyncIterable { + ): ProgressiveResult { const sources = this.sourcesFor(kind, 'search'); - const pending = sources.map(async (src) => { - const t0 = Date.now(); - try { - const results = await src.search!(query, kind, opts); - this.health.record(src.id, true, Date.now() - t0); - return results; - } catch { - this.health.record(src.id, false, Date.now() - t0); - return [] as Media[]; - } - }); - - const settled = await Promise.all(pending); - for (const batch of settled) { - for (const item of batch) { - yield item; - } - } + return createProgressiveResult( + sources.map((src) => async (push, signal) => { + const t0 = Date.now(); + try { + const results = await src.search!(query, kind, { signal }); + this.health.record(src.id, true, Date.now() - t0); + for (const item of results) push(item); + } catch (e) { + this.health.record(src.id, false, Date.now() - t0); + throw e; + } + }), + opts.signal, + ); } async mergeEpisodes( diff --git a/tests/progressive.test.ts b/tests/progressive.test.ts new file mode 100644 index 0000000..34a50db --- /dev/null +++ b/tests/progressive.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { createProgressiveResult } from '../src/progressive.js'; +import { AniError, AniErrorCode } from '../src/errors.js'; + +describe('createProgressiveResult', () => { + it('await collects all items from multiple producers', async () => { + const pr = createProgressiveResult([ + async (push) => { + push('a'); + push('b'); + }, + async (push) => { + push('c'); + }, + ]); + const all = await pr; + expect(all.sort()).toEqual(['a', 'b', 'c']); + }); + + it('async iteration yields items as they arrive', async () => { + const pr = createProgressiveResult([ + async (push) => { + push(1); + push(2); + }, + async (push) => { + push(3); + }, + ]); + const collected: number[] = []; + for await (const item of pr) { + collected.push(item); + } + expect(collected.sort((a, b) => a - b)).toEqual([1, 2, 3]); + }); + + it('cancelled via AbortSignal resolves with Cancelled error', async () => { + const ac = new AbortController(); + const pr = createProgressiveResult( + [ + async (push, sig) => { + await new Promise((r) => setTimeout(r, 50)); + if (!sig.aborted) push('late'); + }, + ], + ac.signal, + ); + + ac.abort(); + await expect(pr).rejects.toSatisfy( + (e: unknown) => e instanceof AniError && e.code === AniErrorCode.Cancelled, + ); + }); + + it('cancel() stops iteration and rejects awaited result', async () => { + const pr = createProgressiveResult([ + async (push, sig) => { + for (let i = 0; i < 100; i++) { + if (sig.aborted) break; + push(i); + } + }, + ]); + + pr.cancel(); + const items: number[] = []; + try { + for await (const item of pr) items.push(item); + } catch { + // cancelled — normal + } + expect(items.length).toBeLessThanOrEqual(100); + }); + + it('empty producers resolve to empty array', async () => { + const pr = createProgressiveResult([]); + expect(await pr).toEqual([]); + }); +}); From 876bd083400f42acf15b7abdcb2ff126a61e89ae Mon Sep 17 00:00:00 2001 From: HEXXT Date: Thu, 18 Jun 2026 16:26:45 +0100 Subject: [PATCH 08/19] =?UTF-8?q?feat(sources):=20Phase=206=20=E2=80=94=20?= =?UTF-8?q?migrate=20all=2011=20providers=20to=20Source=20interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add src/sources/ for all remaining content+catalogue providers: - Catalogue: mal.ts (Jikan), kitsu.ts (JSON:API) — search/info/browse - Playback anime: allmanga.ts, megaplay.ts, animeparadise.ts, anikoto.ts, gogoanime.ts, goyabu.ts — episodes/stream - Playback manga: mangadex.ts, weebcentral.ts, mangapill.ts — chapters/pages All sources: - Implement Source interface (src/sources/base.ts) - Use encodeId/decodeId for opaque base64url IDs - Return Media/Episode/Chapter/Stream/Pages (new 2.0 types) - Pass AbortSignal to every HTTP call Add streamToPayload() helper in screenshotHelper.ts for E2E compat. Update E2E tests for all 11 sources to use new *Source classes. Delete obsolete tests: anilistMeta, baseMetadataProvider, mappingClient, providerUrnRoundTrip (replaced by new source tests or obsolete). Old providers/meta files kept as re-export stubs — deleted in Phase 10. tsc clean, 120 unit tests pass. --- src/sources/allmanga.ts | 369 +++++++++++++++++++++++++ src/sources/anikoto.ts | 129 +++++++++ src/sources/animeparadise.ts | 107 +++++++ src/sources/gogoanime.ts | 162 +++++++++++ src/sources/goyabu.ts | 217 +++++++++++++++ src/sources/kitsu.ts | 122 ++++++++ src/sources/mal.ts | 135 +++++++++ src/sources/mangadex.ts | 99 +++++++ src/sources/mangapill.ts | 108 ++++++++ src/sources/megaplay.ts | 142 ++++++++++ src/sources/weebcentral.ts | 105 +++++++ tests/e2e/allmanga.test.ts | 56 ++-- tests/e2e/anikoto.test.ts | 74 ++--- tests/e2e/anilistMeta.test.ts | 79 ------ tests/e2e/animeparadise.test.ts | 40 ++- tests/e2e/baseMetadataProvider.test.ts | 48 ---- tests/e2e/gogoanime.test.ts | 45 ++- tests/e2e/goyabu.test.ts | 45 ++- tests/e2e/kitsuMeta.test.ts | 66 ++--- tests/e2e/malMeta.test.ts | 100 ++----- tests/e2e/mangadex.test.ts | 50 ++-- tests/e2e/mangadex_pagination.test.ts | 25 +- tests/e2e/mangapill.test.ts | 60 ++-- tests/e2e/mappingClient.test.ts | 89 ------ tests/e2e/megaplay.test.ts | 51 ++-- tests/e2e/providerUrnRoundTrip.test.ts | 39 --- tests/e2e/screenshotHelper.ts | 17 ++ tests/e2e/weebcentral.test.ts | 60 ++-- 28 files changed, 1985 insertions(+), 654 deletions(-) create mode 100644 src/sources/allmanga.ts create mode 100644 src/sources/anikoto.ts create mode 100644 src/sources/animeparadise.ts create mode 100644 src/sources/gogoanime.ts create mode 100644 src/sources/goyabu.ts create mode 100644 src/sources/kitsu.ts create mode 100644 src/sources/mal.ts create mode 100644 src/sources/mangadex.ts create mode 100644 src/sources/mangapill.ts create mode 100644 src/sources/megaplay.ts create mode 100644 src/sources/weebcentral.ts delete mode 100644 tests/e2e/anilistMeta.test.ts delete mode 100644 tests/e2e/baseMetadataProvider.test.ts delete mode 100644 tests/e2e/mappingClient.test.ts delete mode 100644 tests/e2e/providerUrnRoundTrip.test.ts diff --git a/src/sources/allmanga.ts b/src/sources/allmanga.ts new file mode 100644 index 0000000..f6878a8 --- /dev/null +++ b/src/sources/allmanga.ts @@ -0,0 +1,369 @@ +import { HttpClient } from '../internal/http.js'; +import { encodeId, decodeId } from '../internal/id.js'; +import { aesDecryptCtr } from '../utils/crypto.js'; +import { Mp4UploadExtractor } from '../extractors/Mp4UploadExtractor.js'; +import { GenericHlsExtractor } from '../extractors/GenericHlsExtractor.js'; +import type { Media, Episode, Stream, List, Subtitle } from '../types.js'; +import type { IVideoPayload, IMediaMappings } from '../types/index.js'; +import type { Source, SourceCallOpts } from './base.js'; + +const ALLANIME_KEY_PHRASE = 'Xot36i3lK3:v1'; +const ALLANIME_USER_AGENT = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0'; + +function decodeAllAnimeSource(encoded: string): string { + const hex = encoded.startsWith('--') ? encoded.slice(2) : encoded; + const bytes = new Uint8Array(hex.length >>> 1); + for (let i = 0; i < hex.length; i += 2) { + bytes[i >>> 1] = parseInt(hex.substring(i, i + 2), 16) ^ 0x38; + } + return new TextDecoder('latin1').decode(bytes).replace(/\/clock(?=\?|$)/, '/clock.json'); +} + +function mapQuality(label: string): IVideoPayload['quality'] { + const s = String(label).toLowerCase(); + if (s.includes('1080')) return '1080p'; + if (s.includes('720')) return '720p'; + if (s.includes('480')) return '480p'; + if (s.includes('360')) return '360p'; + return 'auto'; +} + +function qualityScore(p: IVideoPayload): number { + let s = 0; + if (/\/video\.mp4(?:[?#]|$)/.test(p.sourceUrl)) s += 30; + if (p.sourceUrl.includes('wixstatic.com')) s += 25; + if (p.sourceUrl.includes('mp4upload.com')) s += 20; + if (/\.m3u8(?:[?#]|$)/.test(p.sourceUrl)) s += 15; + if (/\.mp4(?:[?#]|$)/.test(p.sourceUrl)) s += 10; + if (p.quality === '1080p') s += 4; + else if (p.quality === '720p') s += 3; + else if (p.quality === '480p') s += 2; + else if (p.quality === '360p') s += 1; + return s; +} + +function payloadsToStream(payloads: IVideoPayload[], lang: 'sub' | 'dub' | 'raw'): Stream { + payloads.sort((a, b) => qualityScore(b) - qualityScore(a)); + const primary = payloads[0]; + const url = primary.sourceUrl; + let host = ''; + try { + host = new URL(url).hostname; + } catch {} + return { + url, + origin: { host, url, proxied: false }, + isHls: primary.isHLS, + qualities: payloads.map((p) => ({ label: p.quality, url: p.sourceUrl })), + language: lang, + subtitles: (primary.subtitles ?? []).map( + (s): Subtitle => ({ + url: s.url, + language: s.language, + label: s.label, + format: s.format ?? 'vtt', + }), + ), + headers: primary.headers, + adjacent: {}, + }; +} + +export class AllmangaSource implements Source { + readonly id = 'allmanga'; + readonly kinds = ['anime'] as const; + readonly caps = { search: true, episodes: true, stream: true, mapping: true } as const; + + readonly malsyncSites = ['AllAnime']; + + private http: HttpClient; + private apiBase = 'https://api.allanime.day/api'; + private apiHost = 'https://allanime.day'; + private referer = 'https://allmanga.to'; + private origin = 'https://allmanga.to'; + private mp4UploadExtractor: Mp4UploadExtractor; + private genericExtractor: GenericHlsExtractor; + + constructor(http: HttpClient) { + this.http = http; + this.mp4UploadExtractor = new Mp4UploadExtractor(http); + this.genericExtractor = new GenericHlsExtractor(http); + } + + private apiHeaders(): Record { + return { + 'Content-Type': 'application/json', + Referer: this.referer, + Origin: this.origin, + 'User-Agent': ALLANIME_USER_AGENT, + }; + } + + async search(query: string, _kind: 'anime' | 'manga', opts: SourceCallOpts): Promise { + const gql = `query($search:SearchInput,$limit:Int,$page:Int,$countryOrigin:VaildCountryOriginEnumType){shows(search:$search,limit:$limit,page:$page,countryOrigin:$countryOrigin){edges{_id name englishName availableEpisodes}}}`; + const res = await this.http.post( + this.apiBase, + { + variables: { + search: { allowAdult: false, allowUnknown: false, query }, + limit: 40, + page: 1, + countryOrigin: 'ALL', + }, + query: gql, + }, + { headers: this.apiHeaders(), signal: opts.signal }, + ); + if (res.status !== 200) throw new Error(`AllManga search failed: ${res.status}`); + const json = (await res.json()) as any; + const edges: any[] = json?.data?.shows?.edges ?? []; + return edges + .filter((e) => e.englishName || e.name) + .map((e): Media => { + const title = e.englishName || e.name; + const avail = e.availableEpisodes as Record | undefined; + const langs: ('sub' | 'dub' | 'raw')[] = []; + if (avail?.sub) langs.push('sub'); + if (avail?.dub) langs.push('dub'); + if (avail?.raw) langs.push('raw'); + return { + id: encodeId({ t: 'media', s: this.id, r: e._id }), + kind: 'anime', + title: { preferred: title }, + catalogues: [this.id], + playbackSources: [this.id], + mappings: { sources: { [this.id]: e._id } }, + }; + }); + } + + async episodes( + mediaId: string, + opts: SourceCallOpts & { cursor?: string; limit?: number }, + ): Promise> { + const gql = `query($showId:String!){show(_id:$showId){_id availableEpisodesDetail}}`; + const res = await this.http.post( + this.apiBase, + { variables: { showId: mediaId }, query: gql }, + { headers: this.apiHeaders(), signal: opts.signal }, + ); + if (res.status !== 200) throw new Error(`AllManga episodes failed: ${res.status}`); + const json = (await res.json()) as any; + const detail = json?.data?.show?.availableEpisodesDetail ?? {}; + + const merged = new Map(); + for (const lang of ['sub', 'dub', 'raw'] as const) { + const list: string[] = Array.isArray(detail[lang]) ? detail[lang] : []; + for (const epStr of list) { + const num = parseFloat(epStr); + if (isNaN(num)) continue; + const entry = merged.get(epStr) ?? { num, langs: [] }; + if (!entry.langs.includes(lang)) entry.langs.push(lang); + merged.set(epStr, entry); + } + } + + const items: Episode[] = []; + for (const [epStr, { num, langs }] of merged) { + items.push({ + id: encodeId({ t: 'episode', s: this.id, r: `${mediaId}/${epStr}` }), + mediaId: encodeId({ t: 'media', s: this.id, r: mediaId }), + number: num, + title: `Episode ${epStr}`, + languages: langs, + qualities: ['auto'], + source: this.id, + }); + } + items.sort((a, b) => a.number - b.number); + return { items }; + } + + async stream( + episodeId: string, + opts: SourceCallOpts & { language?: 'sub' | 'dub' | 'raw' }, + ): Promise { + const { r: rawUnit } = decodeId(episodeId); + const [showId, episodeString] = rawUnit.split('/'); + if (!showId || !episodeString) throw new Error(`Invalid AllManga episode id: ${rawUnit}`); + const lang = opts.language ?? 'sub'; + const sources = await this.fetchEpisodeSources(showId, episodeString, lang, opts.signal); + if (sources.length === 0) throw new Error(`AllManga: no sources for ${rawUnit}`); + sources.sort((a, b) => (Number(b.priority) || 0) - (Number(a.priority) || 0)); + + const payloads: IVideoPayload[] = []; + const errors: string[] = []; + for (const src of sources) { + try { + payloads.push(...(await this.extractSource(src, lang))); + } catch (e) { + errors.push(`${src.sourceName}: ${(e as Error).message}`); + } + } + if (payloads.length === 0) { + throw new Error(`AllManga: no playable streams for ${rawUnit}. Errors: ${errors.join('; ')}`); + } + return payloadsToStream(payloads, lang); + } + + async lookupByMapping( + mappings: Record, + _opts?: SourceCallOpts, + ): Promise { + const m = mappings as IMediaMappings; + if (m.anilist) { + const results = await this.search(String(m.anilist), 'anime', {}); + return results[0]?.mappings.sources?.['allmanga'] ?? null; + } + return null; + } + + private async fetchEpisodeSources( + showId: string, + episodeString: string, + lang: 'sub' | 'dub' | 'raw', + signal?: AbortSignal, + ): Promise> { + const variables = { showId, translationType: lang, episodeString }; + const extensions = { + persistedQuery: { + version: 1, + sha256Hash: 'd405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec', + }, + }; + const url = `${this.apiBase}?variables=${encodeURIComponent(JSON.stringify(variables))}&extensions=${encodeURIComponent(JSON.stringify(extensions))}`; + const res = await this.http.get(url, { headers: this.apiHeaders(), signal }); + if (res.status !== 200) throw new Error(`AllManga stream sources failed: ${res.status}`); + const json = (await res.json()) as any; + const tobeparsed: string | undefined = json?.data?.tobeparsed; + if (tobeparsed) return this.decryptTobeparsed(tobeparsed); + const fallbackQuery = `query($showId:String!,$translationType:VaildTranslationTypeEnumType!,$episodeString:String!){episode(showId:$showId translationType:$translationType episodeString:$episodeString){episodeString sourceUrls}}`; + const fbRes = await this.http.post( + this.apiBase, + { variables, query: fallbackQuery }, + { headers: this.apiHeaders(), signal }, + ); + if (fbRes.status !== 200) throw new Error(`AllManga fallback failed: ${fbRes.status}`); + const fbJson = (await fbRes.json()) as any; + return fbJson?.data?.episode?.sourceUrls ?? []; + } + + private async decryptTobeparsed(blob: string): Promise { + let binary: string; + try { + binary = atob(blob); + } catch { + const norm = blob.replace(/-/g, '+').replace(/_/g, '/'); + const pad = norm.length % 4; + binary = atob(pad ? norm + '='.repeat(4 - pad) : norm); + } + const data = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) data[i] = binary.charCodeAt(i); + if (data.length < 30) throw new Error(`tobeparsed too short (${data.length} bytes)`); + const nonce = data.subarray(1, 13); + const ciphertext = data.subarray(13, data.length - 16); + const keyBytes = new TextEncoder().encode(ALLANIME_KEY_PHRASE); + const keyHash = await globalThis.crypto.subtle.digest('SHA-256', keyBytes); + const key = new Uint8Array(keyHash); + const iv = new Uint8Array(16); + iv.set(nonce, 0); + new DataView(iv.buffer).setUint32(12, 2, false); + const decrypted = await aesDecryptCtr(ciphertext, key, iv); + const text = new TextDecoder().decode(decrypted); + const parsed = JSON.parse(text); + const sources = parsed.episode?.sourceUrls ?? parsed.data?.episode?.sourceUrls ?? null; + if (!Array.isArray(sources)) throw new Error('No sourceUrls in decrypted tobeparsed'); + return sources; + } + + private async extractSource( + src: { sourceUrl: string; sourceName?: string }, + lang: 'sub' | 'dub' | 'raw', + ): Promise { + let raw = src.sourceUrl; + if (!raw) return []; + if (raw.startsWith('--')) { + raw = decodeAllAnimeSource(raw); + if (raw.startsWith('/')) raw = this.apiHost + raw; + } + if (raw.startsWith('//')) raw = 'https:' + raw; + const headers = { Referer: this.referer, 'User-Agent': ALLANIME_USER_AGENT }; + if (raw.includes('/clock.json')) return this.resolveClockJson(raw, lang); + if (/\.m3u8(?:\?|$)/.test(raw) || /\.mp4(?:\?|$)/.test(raw)) { + return [ + { sourceUrl: raw, isHLS: raw.includes('.m3u8'), quality: 'auto', language: lang, headers }, + ]; + } + if (raw.includes('tools.fast4speed.rsvp')) { + return [{ sourceUrl: raw, isHLS: false, quality: 'auto', language: lang, headers }]; + } + if (Mp4UploadExtractor.matches(raw)) return this.mp4UploadExtractor.extract(raw); + try { + const extracted = await this.genericExtractor.extract(raw); + if (extracted.length > 0) return extracted.map((p) => ({ ...p, language: lang })); + } catch {} + return []; + } + + private async resolveClockJson( + url: string, + lang: 'sub' | 'dub' | 'raw', + ): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 8000); + try { + const res = await this.http.get(url, { + headers: { Referer: this.referer, 'User-Agent': ALLANIME_USER_AGENT }, + signal: controller.signal as AbortSignal, + }); + clearTimeout(timer); + if (res.status !== 200) throw new Error(`clock.json returned ${res.status}`); + const json = (await res.json()) as any; + const links = json.links ?? []; + const out: IVideoPayload[] = []; + for (const item of links) { + const link = item.link as string | undefined; + if (!link) continue; + if (link.includes('repackager.wixmp.com')) { + const m = link.match(/\/,([^/]+),\/mp4/); + if (m) { + const qualities = m[1].split(','); + const cleanBase = link + .replace('repackager.wixmp.com/', '') + .replace(/\.urlset\/master\.m3u8$/, ''); + for (const q of qualities) { + const streamUrl = cleanBase.replace(`/,${m[1]},/mp4/`, `/${q}/mp4/`); + out.push({ + sourceUrl: streamUrl, + isHLS: false, + quality: mapQuality(q), + language: lang, + headers: { Referer: this.referer }, + }); + } + continue; + } + out.push({ + sourceUrl: link, + isHLS: true, + quality: 'auto', + language: lang, + headers: { Referer: this.referer }, + }); + continue; + } + out.push({ + sourceUrl: link, + isHLS: !!item.hls || link.includes('.m3u8'), + quality: mapQuality(item.resolutionStr ?? ''), + language: lang, + headers: { Referer: this.referer }, + }); + } + return out; + } finally { + clearTimeout(timer); + } + } +} diff --git a/src/sources/anikoto.ts b/src/sources/anikoto.ts new file mode 100644 index 0000000..4e1ca10 --- /dev/null +++ b/src/sources/anikoto.ts @@ -0,0 +1,129 @@ +import { HttpClient } from '../internal/http.js'; +import { DomRegistry } from '../internal/dom.js'; +import { encodeId, decodeId } from '../internal/id.js'; +import type { Media, Episode, Stream, List, Subtitle } from '../types.js'; +import type { Source, SourceCallOpts } from './base.js'; + +export class AnikotoSource implements Source { + readonly id = 'anikoto'; + readonly kinds = ['anime'] as const; + readonly caps = { search: true, episodes: true, stream: true } as const; + + private readonly baseUrl = 'https://anikototv.to'; + private readonly apiUrl = 'https://anikotoapi.site'; + + constructor(private http: HttpClient) { + if (!http.getDefaultHeaders()['User-Agent']) { + http.setUserAgent( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + ); + } + } + + async search(query: string, _kind: 'anime' | 'manga', opts: SourceCallOpts): Promise { + const res = await this.http.get(`${this.baseUrl}/filter?keyword=${encodeURIComponent(query)}`, { + signal: opts.signal, + }); + const doc = DomRegistry.parse(await res.text()); + return doc + .querySelectorAll('.main .item') + .map((item): Media | null => { + const posterEl = item.querySelector('.poster'); + const id = posterEl?.getAttribute('data-tip') || ''; + if (!id) return null; + const title = item.querySelector('.name')?.textContent?.trim() || ''; + const imgSrc = item.querySelector('img')?.getAttribute('src') ?? undefined; + return { + id: encodeId({ t: 'media', s: this.id, r: id }), + kind: 'anime', + title: { preferred: title }, + cover: imgSrc ? { url: imgSrc } : undefined, + catalogues: [this.id], + playbackSources: [this.id], + mappings: { sources: { [this.id]: id } }, + }; + }) + .filter((r): r is Media => r !== null); + } + + async episodes( + mediaId: string, + opts: SourceCallOpts & { cursor?: string; limit?: number }, + ): Promise> { + const res = await this.http.get(`${this.apiUrl}/series/${mediaId}`, { + signal: opts.signal, + }); + const json = (await res.json()) as any; + if (!json.ok || !json.data?.episodes) return { items: [] }; + return { + items: json.data.episodes.map( + (ep: any): Episode => ({ + id: encodeId({ t: 'episode', s: this.id, r: ep.episode_embed_id }), + mediaId: encodeId({ t: 'media', s: this.id, r: mediaId }), + number: ep.number, + title: ep.title || `Episode ${ep.number}`, + languages: [ + ...(ep.embed_url?.sub ? ['sub' as const] : []), + ...(ep.embed_url?.dub ? ['dub' as const] : []), + ], + qualities: ['auto'], + source: this.id, + }), + ), + }; + } + + async stream( + episodeId: string, + opts: SourceCallOpts & { language?: 'sub' | 'dub' | 'raw' }, + ): Promise { + const { r: rawUnit } = decodeId(episodeId); + const lang = opts.language ?? 'sub'; + const embedUrl = `https://megaplay.buzz/stream/s-2/${rawUnit}/${lang}`; + const embedRes = await this.http.get(embedUrl, { + signal: opts.signal, + headers: { Referer: this.baseUrl }, + }); + const embedPage = await embedRes.text(); + const fileIdMatch = embedPage.match(/File\s+(\d+)\s+-/); + if (!fileIdMatch) throw new Error('Anikoto: no file ID on megaplay embed page'); + const fileId = fileIdMatch[1]; + + const srcRes = await this.http.get(`https://megaplay.buzz/stream/getSources?id=${fileId}`, { + signal: opts.signal, + headers: { + Referer: `https://megaplay.buzz/stream/s-5/${rawUnit}/${lang}`, + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + const srcJson = (await srcRes.json()) as any; + if (!srcJson.sources?.file) throw new Error('Anikoto: no video sources'); + + const url: string = srcJson.sources.file; + let host = ''; + try { + host = new URL(url).hostname; + } catch {} + const subtitles: Subtitle[] = ((srcJson.tracks ?? []) as any[]) + .filter((t) => t.kind === 'captions') + .map( + (t): Subtitle => ({ + url: t.file, + label: t.label, + language: String(t.label).toLowerCase(), + format: String(t.file).endsWith('.vtt') ? 'vtt' : 'srt', + }), + ); + + return { + url, + origin: { host, url, proxied: false }, + isHls: url.includes('.m3u8'), + qualities: [{ label: 'auto', url }], + language: lang, + subtitles, + headers: { Referer: 'https://megaplay.buzz/' }, + adjacent: {}, + }; + } +} diff --git a/src/sources/animeparadise.ts b/src/sources/animeparadise.ts new file mode 100644 index 0000000..a32a700 --- /dev/null +++ b/src/sources/animeparadise.ts @@ -0,0 +1,107 @@ +import { HttpClient } from '../internal/http.js'; +import { encodeId, decodeId } from '../internal/id.js'; +import { normalizeSubtitleEntries } from '../utils/subtitles.js'; +import type { Media, Episode, Stream, List, Subtitle } from '../types.js'; +import type { Source, SourceCallOpts } from './base.js'; + +const API_BASE = 'https://api.animeparadise.moe'; +const STREAM_BASE = 'https://stream.animeparadise.moe'; + +export class AnimeParadiseSource implements Source { + readonly id = 'animeparadise'; + readonly kinds = ['anime'] as const; + readonly caps = { search: true, episodes: true, stream: true } as const; + + constructor(private http: HttpClient) {} + + async search(query: string, _kind: 'anime' | 'manga', opts: SourceCallOpts): Promise { + const res = await this.http.get(`${API_BASE}/search?q=${encodeURIComponent(query)}&limit=20`, { + signal: opts.signal, + }); + const json = (await res.json()) as any; + return ((json?.data as any[]) ?? []).map( + (item): Media => ({ + id: encodeId({ t: 'media', s: this.id, r: item._id }), + kind: 'anime', + title: { preferred: item.alternativeTitle?.english ?? item.title ?? '' }, + cover: item.posterImage?.medium + ? { url: item.posterImage.medium } + : item.posterImage?.large + ? { url: item.posterImage.large } + : undefined, + year: + typeof item.year === 'number' + ? item.year + : item.released + ? new Date(item.released).getUTCFullYear() + : undefined, + catalogues: [this.id], + playbackSources: [this.id], + mappings: { sources: { [this.id]: item._id } }, + }), + ); + } + + async episodes( + mediaId: string, + opts: SourceCallOpts & { cursor?: string; limit?: number }, + ): Promise> { + const res = await this.http.get(`${API_BASE}/anime/${mediaId}/episode`, { + signal: opts.signal, + }); + const json = (await res.json()) as any; + const episodes: any[] = json?.data ?? []; + return { + items: episodes.map( + (ep): Episode => ({ + id: encodeId({ t: 'episode', s: this.id, r: `${ep.uid}:${mediaId}` }), + mediaId: encodeId({ t: 'media', s: this.id, r: mediaId }), + number: parseFloat(ep.number), + title: ep.title ?? `Episode ${ep.number}`, + languages: ['sub'], + qualities: ['auto'], + source: this.id, + }), + ), + }; + } + + async stream(episodeId: string, opts: SourceCallOpts): Promise { + const { r: rawUnit } = decodeId(episodeId); + const sep = rawUnit.lastIndexOf(':'); + if (sep < 0) throw new Error(`AnimeParadise: invalid episode id: ${rawUnit}`); + const uid = rawUnit.slice(0, sep); + const animeId = rawUnit.slice(sep + 1); + + const res = await this.http.get(`${API_BASE}/ep/${uid}?origin=${animeId}`, { + signal: opts.signal, + }); + const json = (await res.json()) as any; + const episode = json?.data?.episode; + if (!episode?.streamLink) throw new Error('AnimeParadise: no streamLink in response'); + + const url = `${STREAM_BASE}/m3u8?url=${encodeURIComponent(episode.streamLink)}`; + let host = ''; + try { + host = new URL(url).hostname; + } catch {} + const rawSubs = normalizeSubtitleEntries(episode.subData); + const subtitles: Subtitle[] = rawSubs.map((s) => ({ + url: s.url, + language: s.language, + label: s.label, + format: (s.format ?? 'vtt') as 'vtt' | 'srt' | 'ass', + })); + + return { + url, + origin: { host, url, proxied: false }, + isHls: true, + qualities: [{ label: 'auto', url }], + language: 'sub', + subtitles, + headers: { Referer: 'https://animeparadise.moe/' }, + adjacent: {}, + }; + } +} diff --git a/src/sources/gogoanime.ts b/src/sources/gogoanime.ts new file mode 100644 index 0000000..c24259f --- /dev/null +++ b/src/sources/gogoanime.ts @@ -0,0 +1,162 @@ +import { HttpClient } from '../internal/http.js'; +import { DomRegistry } from '../internal/dom.js'; +import { GenericHlsExtractor } from '../extractors/GenericHlsExtractor.js'; +import { encodeId, decodeId } from '../internal/id.js'; +import type { Media, Episode, Stream, List } from '../types.js'; +import type { IVideoPayload } from '../types/index.js'; +import type { Source, SourceCallOpts } from './base.js'; + +export class GogoanimeSource implements Source { + readonly id = 'gogoanime'; + readonly kinds = ['anime'] as const; + readonly caps = { search: true, episodes: true, stream: true } as const; + + private baseUrl: string; + + constructor( + private http: HttpClient, + baseUrl = 'https://anineko.to', + ) { + this.baseUrl = baseUrl; + if (!http.getDefaultHeaders()['User-Agent']) { + http.setUserAgent( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + ); + } + } + + async search(query: string, _kind: 'anime' | 'manga', opts: SourceCallOpts): Promise { + const res = await this.http.get( + `${this.baseUrl}/browser?keyword=${encodeURIComponent(query)}`, + { signal: opts.signal }, + ); + if (res.status !== 200) throw new Error(`Gogoanime search failed: ${res.status}`); + const html = await res.text(); + const doc = DomRegistry.parse(html); + const out: Media[] = []; + for (const card of doc.querySelectorAll('article.nv-anime-card')) { + const a = card.querySelector('h3.nv-anime-title a') || card.querySelector('a.nv-anime-thumb'); + if (!a) continue; + const href = a.getAttribute('href') || ''; + if (!href) continue; + const id = href.startsWith('/') ? href : `/${href}`; + const title = a.getAttribute('title') || (a.textContent || '').trim(); + const img = card.querySelector('img'); + const src = img?.getAttribute('src') ?? ''; + const coverUrl = src + ? src.startsWith('http') + ? src + : `${this.baseUrl}${src.startsWith('/') ? '' : '/'}${src}` + : undefined; + out.push({ + id: encodeId({ t: 'media', s: this.id, r: id }), + kind: 'anime', + title: { preferred: title }, + cover: coverUrl ? { url: coverUrl } : undefined, + catalogues: [this.id], + playbackSources: [this.id], + mappings: { sources: { [this.id]: id } }, + }); + } + return out; + } + + async episodes( + mediaId: string, + opts: SourceCallOpts & { cursor?: string; limit?: number }, + ): Promise> { + let watchUrlPath = mediaId; + if (mediaId.includes('/watch/')) { + const parts = mediaId.split('/'); + if (parts.length > 3) watchUrlPath = `/${parts[1]}/${parts[2]}`; + } else { + const slug = mediaId.startsWith('/') ? mediaId.substring(1) : mediaId; + watchUrlPath = `/watch/${slug}`; + } + const fullUrl = `${this.baseUrl}${watchUrlPath.startsWith('/') ? '' : '/'}${watchUrlPath}`; + const res = await this.http.get(fullUrl, { signal: opts.signal }); + if (res.status !== 200) throw new Error(`Gogoanime episodes failed: ${res.status}`); + const doc = DomRegistry.parse(await res.text()); + const items: Episode[] = []; + for (const item of doc.querySelectorAll('article.nv-info-episode-item')) { + const a = item.querySelector('a.nv-info-episode-main'); + if (!a) continue; + const href = a.getAttribute('href') || ''; + if (!href) continue; + const epId = href.startsWith('/') ? href : `/${href}`; + const strong = a.querySelector('strong'); + const span = a.querySelector('span'); + const numText = strong ? (strong.textContent || '').trim() : ''; + const titleText = span ? (span.textContent || '').trim() : ''; + const epMatch = href.match(/ep-(\d+(\.\d+)?)/); + const number = epMatch ? parseFloat(epMatch[1]) : 0; + const displayTitle = titleText ? `${numText} - ${titleText}` : numText || `Episode ${number}`; + items.push({ + id: encodeId({ t: 'episode', s: this.id, r: epId }), + mediaId: encodeId({ t: 'media', s: this.id, r: mediaId }), + number, + title: displayTitle, + languages: [mediaId.toLowerCase().includes('-dub') ? 'dub' : 'sub'], + qualities: ['auto'], + source: this.id, + }); + } + items.sort((a, b) => a.number - b.number); + return { items }; + } + + async stream(episodeId: string, opts: SourceCallOpts): Promise { + const { r: rawUnit } = decodeId(episodeId); + const fullUrl = `${this.baseUrl}${rawUnit.startsWith('/') ? '' : '/'}${rawUnit}`; + const res = await this.http.get(fullUrl, { signal: opts.signal }); + if (res.status !== 200) throw new Error(`Gogoanime stream failed: ${res.status}`); + const doc = DomRegistry.parse(await res.text()); + const embeds: IVideoPayload[] = []; + for (const btn of doc.querySelectorAll('button.nv-server-btn')) { + const videoUrl = btn.getAttribute('data-video'); + if (!videoUrl) continue; + let absoluteUrl = videoUrl; + if (videoUrl.startsWith('//')) absoluteUrl = 'https:' + videoUrl; + else if (videoUrl.startsWith('/')) absoluteUrl = this.baseUrl.replace(/\/$/, '') + videoUrl; + const label = (btn.textContent || '').toLowerCase(); + let quality: IVideoPayload['quality'] = 'auto'; + if (label.includes('1080')) quality = '1080p'; + else if (label.includes('720')) quality = '720p'; + else if (label.includes('360')) quality = '360p'; + embeds.push({ + sourceUrl: absoluteUrl, + isHLS: absoluteUrl.includes('.m3u8'), + quality, + headers: { Referer: fullUrl }, + }); + } + if (embeds.length === 0) throw new Error(`Gogoanime: no server buttons on ${rawUnit}`); + const extractor = new GenericHlsExtractor(this.http); + let resolved: IVideoPayload[] = []; + for (const embed of embeds) { + try { + const extracted = await extractor.extract(embed.sourceUrl); + if (extracted.length > 0) { + resolved = extracted; + break; + } + } catch {} + } + const payloads = resolved.length > 0 ? resolved : embeds; + const primary = payloads[0]; + let host = ''; + try { + host = new URL(primary.sourceUrl).hostname; + } catch {} + return { + url: primary.sourceUrl, + origin: { host, url: primary.sourceUrl, proxied: false }, + isHls: primary.isHLS, + qualities: payloads.map((p) => ({ label: p.quality, url: p.sourceUrl })), + language: 'sub', + subtitles: [], + headers: primary.headers, + adjacent: {}, + }; + } +} diff --git a/src/sources/goyabu.ts b/src/sources/goyabu.ts new file mode 100644 index 0000000..c92f38f --- /dev/null +++ b/src/sources/goyabu.ts @@ -0,0 +1,217 @@ +import { HttpClient } from '../internal/http.js'; +import { DomRegistry } from '../internal/dom.js'; +import { BloggerExtractor } from '../extractors/BloggerExtractor.js'; +import { encodeId, decodeId } from '../internal/id.js'; +import type { Media, Episode, Stream, List } from '../types.js'; +import type { IVideoPayload } from '../types/index.js'; +import type { Source, SourceCallOpts } from './base.js'; + +export class GoyabuSource implements Source { + readonly id = 'goyabu'; + readonly kinds = ['anime'] as const; + readonly caps = { search: true, episodes: true, stream: true } as const; + + private baseUrl: string; + private bloggerExtractor: BloggerExtractor; + + constructor( + private http: HttpClient, + baseUrl = 'https://goyabu.io', + ) { + this.baseUrl = baseUrl; + if (!http.getDefaultHeaders()['User-Agent']) { + http.setUserAgent( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + ); + } + this.bloggerExtractor = new BloggerExtractor(http); + } + + async search(query: string, _kind: 'anime' | 'manga', opts: SourceCallOpts): Promise { + const normalized = query.trim().replace(/[-_]/g, ' '); + const res = await this.http.get(`${this.baseUrl}/?s=${encodeURIComponent(normalized)}`, { + signal: opts.signal, + }); + if (res.status !== 200) throw new Error(`Goyabu search failed: ${res.status}`); + const doc = DomRegistry.parse(await res.text()); + const out: Media[] = []; + const cards = doc.querySelectorAll('article.boxAN') || doc.querySelectorAll('article'); + for (const card of cards) { + const a = card.querySelector('a'); + if (!a) continue; + const href = a.getAttribute('href') || ''; + if (!href || !href.includes('/anime/')) continue; + const id = href.startsWith('http') ? new URL(href).pathname : href; + const titleElem = + card.querySelector('.title') || card.querySelector('h3') || card.querySelector('h2'); + let title = titleElem ? (titleElem.textContent || '').trim() : ''; + const img = card.querySelector('img'); + if (!title && img) + title = (img.getAttribute('alt') || img.getAttribute('title') || '').trim(); + if (!title) continue; + const src = img?.getAttribute('src') || img?.getAttribute('data-src') || ''; + const coverUrl = src + ? src.startsWith('http') + ? src + : `${this.baseUrl}${src.startsWith('/') ? '' : '/'}${src}` + : undefined; + out.push({ + id: encodeId({ t: 'media', s: this.id, r: id }), + kind: 'anime', + title: { preferred: title }, + cover: coverUrl ? { url: coverUrl } : undefined, + catalogues: [this.id], + playbackSources: [this.id], + mappings: { sources: { [this.id]: id } }, + }); + } + return out; + } + + async episodes( + mediaId: string, + opts: SourceCallOpts & { cursor?: string; limit?: number }, + ): Promise> { + const fullUrl = `${this.baseUrl}${mediaId.startsWith('/') ? '' : '/'}${mediaId}`; + const res = await this.http.get(fullUrl, { signal: opts.signal }); + if (res.status !== 200) throw new Error(`Goyabu episodes failed: ${res.status}`); + const html = await res.text(); + const items: Episode[] = []; + const patterns = [ + /(?:const|let|var)\s+allEpisodes\s*=\s*(\[[\s\S]*?\])\s*;/i, + /episodes\s*[:=]\s*(\[[\s\S]*?\])/i, + /"episodes"\s*:\s*(\[[\s\S]*?\])/i, + ]; + let parsed = false; + for (const pattern of patterns) { + const match = html.match(pattern); + if (!match) continue; + try { + let cleaned = match[1].replace(/([,{\[\s]|^)(\w+)\s*:/g, '$1"$2":'); + cleaned = cleaned.replace(/'/g, '"').replace(/,\s*([\}\]])/g, '$1'); + const epData = JSON.parse(cleaned); + if (Array.isArray(epData)) { + for (let i = 0; i < epData.length; i++) { + const ep = epData[i]; + const num = ep.episodio ? parseFloat(ep.episodio) : i + 1; + const link = ep.link || (ep.id ? `/${ep.id}` : ep.ID ? `/${ep.ID}` : ''); + if (!link) continue; + items.push({ + id: encodeId({ t: 'episode', s: this.id, r: link }), + mediaId: encodeId({ t: 'media', s: this.id, r: mediaId }), + number: num, + title: ep.episode_name ? `Episódio ${num}: ${ep.episode_name}` : `Episódio ${num}`, + languages: [mediaId.toLowerCase().includes('dublado') ? 'dub' : 'sub'], + qualities: ['auto'], + source: this.id, + }); + } + parsed = true; + break; + } + } catch {} + } + if (!parsed) { + const doc = DomRegistry.parse(html); + for (const a of doc.querySelectorAll('a')) { + const href = a.getAttribute('href') || ''; + if (!href) continue; + if (!href.includes('/?p=') && !href.includes('/episode/')) continue; + if (!href.includes(this.baseUrl) && !href.startsWith('/')) continue; + const num = items.length + 1; + const id = href.startsWith('http') ? new URL(href).pathname + new URL(href).search : href; + items.push({ + id: encodeId({ t: 'episode', s: this.id, r: id }), + mediaId: encodeId({ t: 'media', s: this.id, r: mediaId }), + number: num, + title: `Episódio ${num}`, + languages: ['sub'], + qualities: ['auto'], + source: this.id, + }); + } + } + items.sort((a, b) => a.number - b.number); + return { items }; + } + + async stream(episodeId: string, opts: SourceCallOpts): Promise { + const { r: rawUnit } = decodeId(episodeId); + const fullUrl = `${this.baseUrl}${rawUnit.startsWith('/') ? '' : '/'}${rawUnit}`; + const res = await this.http.get(fullUrl, { signal: opts.signal }); + if (res.status !== 200) throw new Error(`Goyabu stream page failed: ${res.status}`); + const html = await res.text(); + + const bloggerUrls = this.collectBloggerUrls(html); + const payloads: IVideoPayload[] = []; + for (const url of bloggerUrls) { + try { + const extracted = await this.bloggerExtractor.extract(url); + payloads.push(...extracted); + } catch {} + } + if (payloads.length === 0) { + payloads.push(...this.scrapeDirectStreams(html, fullUrl)); + } + if (payloads.length === 0) throw new Error(`Goyabu: no playable streams for ${rawUnit}`); + const primary = payloads[0]; + let host = ''; + try { + host = new URL(primary.sourceUrl).hostname; + } catch {} + return { + url: primary.sourceUrl, + origin: { host, url: primary.sourceUrl, proxied: false }, + isHls: primary.isHLS, + qualities: payloads.map((p) => ({ label: p.quality, url: p.sourceUrl })), + language: 'sub', + subtitles: [], + headers: primary.headers, + adjacent: {}, + }; + } + + private collectBloggerUrls(html: string): string[] { + const urls = new Set(); + const m = html.match(/playersData\s*=\s*(\[[\s\S]*?\])\s*;/i); + if (m) { + try { + const cleaned = m[1].replace(/\\\//g, '/'); + const players = JSON.parse(cleaned); + if (Array.isArray(players)) { + for (const p of players) { + if (typeof p?.url === 'string' && p.url.includes('blogger.com/video.g')) + urls.add(p.url); + } + } + } catch {} + } + const re = /https?:(?:\\\/|\/)\/www\.blogger\.com\/video\.g\?token=[A-Za-z0-9_-]+/g; + let match: RegExpExecArray | null; + while ((match = re.exec(html)) !== null) urls.add(match[0].replace(/\\\//g, '/')); + return Array.from(urls); + } + + private scrapeDirectStreams(html: string, refererUrl: string): IVideoPayload[] { + const out: IVideoPayload[] = []; + const seen = new Set(); + const patterns: Array<[RegExp, IVideoPayload['quality']]> = [ + [/"file"\s*:\s*"(https?:\/\/[^"]+?\.m3u8[^"]*)"/i, 'auto'], + [/"file"\s*:\s*"(https?:\/\/[^"]+?\.mp4[^"]*)"/i, 'auto'], + [/src\s*[:=]\s*["'](https?:\/\/[^"']+?\.m3u8[^"']*)["']/i, 'auto'], + ]; + for (const [re, q] of patterns) { + const m = html.match(re); + if (m && !seen.has(m[1])) { + seen.add(m[1]); + out.push({ + sourceUrl: m[1], + isHLS: m[1].includes('.m3u8'), + quality: q, + headers: { Referer: refererUrl }, + }); + } + } + return out; + } +} diff --git a/src/sources/kitsu.ts b/src/sources/kitsu.ts new file mode 100644 index 0000000..9f0758c --- /dev/null +++ b/src/sources/kitsu.ts @@ -0,0 +1,122 @@ +import { HttpClient } from '../internal/http.js'; +import { encodeId, decodeId } from '../internal/id.js'; +import type { Media, List } from '../types.js'; +import type { Source, SourceCallOpts } from './base.js'; + +const KITSU_API = 'https://kitsu.io/api/edge'; + +function kitsuTitles(a: any): Media['title'] { + const t = a?.titles ?? {}; + return { + preferred: a?.canonicalTitle ?? t.en_jp ?? t.en ?? '', + english: t.en ?? undefined, + romaji: t.en_jp ?? a?.canonicalTitle ?? undefined, + native: t.ja_jp ?? undefined, + }; +} + +function mapNode(r: any, path: 'anime' | 'manga', sourceId: string, mappings = {}): Media { + const a = r.attributes ?? {}; + const kitsuId = Number(r.id); + return { + id: encodeId({ + t: 'media', + s: sourceId, + r: `${path}:${r.id}`, + m: { kitsu: kitsuId, ...mappings }, + }), + kind: path === 'manga' ? 'manga' : 'anime', + title: kitsuTitles(a), + cover: a.posterImage?.large + ? { url: a.posterImage.large } + : a.posterImage?.original + ? { url: a.posterImage.original } + : undefined, + banner: a.coverImage?.large ?? a.coverImage?.original ?? undefined, + score: + typeof a.averageRating === 'string' + ? { value: Math.round(parseFloat(a.averageRating)), scale: 100 } + : undefined, + year: parseYear(a.startDate), + status: a.status ?? undefined, + format: a.subtype ?? undefined, + episodeCount: a.episodeCount ?? undefined, + chapterCount: a.chapterCount ?? undefined, + catalogues: [sourceId], + playbackSources: [], + mappings: { kitsu: kitsuId, ...(mappings as Record) } as Media['mappings'], + }; +} + +function parseYear(d: unknown): number | undefined { + if (typeof d !== 'string') return undefined; + const m = d.match(/^(\d{4})/); + return m ? Number(m[1]) : undefined; +} + +function pickMappings( + included: any[], + refs: Array<{ id: string; type: string }> | undefined, +): { anilist?: number; mal?: number } { + if (!Array.isArray(refs)) return {}; + const out: { anilist?: number; mal?: number } = {}; + for (const ref of refs) { + const node = included.find((i: any) => i.type === 'mappings' && i.id === ref.id); + const site: string = node?.attributes?.externalSite ?? ''; + const extId = node?.attributes?.externalId; + const num = Number(extId); + if (!Number.isFinite(num)) continue; + if (site.startsWith('myanimelist')) out.mal = num; + else if (site.startsWith('anilist')) out.anilist = num; + } + return out; +} + +export class KitsuSource implements Source { + readonly id = 'kitsu'; + readonly kinds = ['anime', 'manga'] as const; + readonly caps = { search: true, info: true } as const; + + private http: HttpClient; + private apiUrl: string; + + constructor(http: HttpClient, apiUrl = KITSU_API) { + this.http = http; + this.apiUrl = apiUrl; + } + + async search(query: string, kind: 'anime' | 'manga', opts: SourceCallOpts): Promise { + const path = kind === 'manga' ? 'manga' : 'anime'; + const url = `${this.apiUrl}/${path}?filter[text]=${encodeURIComponent(query)}&page[limit]=20`; + const res = await this.http.get(url, { + headers: { Accept: 'application/vnd.api+json' }, + signal: opts.signal, + }); + if (res.status !== 200) throw new Error(`Kitsu search failed: ${res.status}`); + const json = (await res.json()) as any; + return ((json?.data as any[]) ?? []).map((r) => mapNode(r, path, this.id)); + } + + async info(id: string, opts: SourceCallOpts): Promise { + const { r: raw } = decodeId(id); + const sep = raw.indexOf(':'); + let path: 'anime' | 'manga' = 'anime'; + let rawId = raw; + if (sep >= 0 && (raw.slice(0, sep) === 'anime' || raw.slice(0, sep) === 'manga')) { + path = raw.slice(0, sep) as 'anime' | 'manga'; + rawId = raw.slice(sep + 1); + } + const url = `${this.apiUrl}/${path}/${rawId}?include=genres,categories,mappings`; + const res = await this.http.get(url, { + headers: { Accept: 'application/vnd.api+json' }, + signal: opts.signal, + }); + if (res.status !== 200) throw new Error(`Kitsu info failed: ${res.status}`); + const json = (await res.json()) as any; + const r = json?.data; + if (!r) throw new Error(`Kitsu: no media for id ${raw}`); + const included: any[] = json?.included ?? []; + const extraMappings = pickMappings(included, r.relationships?.mappings?.data); + return mapNode(r, path, this.id, extraMappings); + } +} diff --git a/src/sources/mal.ts b/src/sources/mal.ts new file mode 100644 index 0000000..368669c --- /dev/null +++ b/src/sources/mal.ts @@ -0,0 +1,135 @@ +import { HttpClient } from '../internal/http.js'; +import { encodeId, decodeId } from '../internal/id.js'; +import type { Media, List } from '../types.js'; +import type { Source, SourceCallOpts } from './base.js'; + +const JIKAN_API = 'https://api.jikan.moe/v4'; + +function malTitles(m: any): Media['title'] { + const titles: Record = {}; + if (Array.isArray(m.titles)) { + for (const t of m.titles) { + const type = String(t?.type ?? '').toLowerCase(); + if (t?.title) titles[type] = t.title; + } + } + return { + preferred: titles['english'] ?? m.title_english ?? m.title ?? '', + english: titles['english'] ?? m.title_english ?? undefined, + romaji: titles['default'] ?? m.title ?? undefined, + native: titles['japanese'] ?? m.title_japanese ?? undefined, + }; +} + +function malScore(s: unknown): Media['score'] { + if (typeof s !== 'number') return undefined; + return { value: Math.round(s * 10), scale: 100 }; +} + +function mapNode(m: any, path: 'anime' | 'manga', sourceId: string): Media { + return { + id: encodeId({ + t: 'media', + s: sourceId, + r: `${path}:${m.mal_id}`, + m: { mal: m.mal_id }, + }), + kind: path === 'manga' ? 'manga' : 'anime', + title: malTitles(m), + cover: m.images?.jpg?.large_image_url + ? { url: m.images.jpg.large_image_url } + : m.images?.jpg?.image_url + ? { url: m.images.jpg.image_url } + : undefined, + score: malScore(m.score), + year: m.year ?? (m.aired?.from ? new Date(m.aired.from).getUTCFullYear() : undefined), + format: m.type ?? undefined, + status: m.status ?? undefined, + episodeCount: m.episodes ?? undefined, + chapterCount: m.chapters ?? undefined, + catalogues: [sourceId], + playbackSources: [], + mappings: { mal: m.mal_id }, + }; +} + +export class MalSource implements Source { + readonly id = 'mal'; + readonly kinds = ['anime', 'manga'] as const; + readonly caps = { search: true, info: true, browse: true } as const; + + private http: HttpClient; + private apiUrl: string; + + constructor(http: HttpClient, apiUrl = JIKAN_API) { + this.http = http; + this.apiUrl = apiUrl; + } + + async search(query: string, kind: 'anime' | 'manga', opts: SourceCallOpts): Promise { + const path = kind === 'manga' ? 'manga' : 'anime'; + const url = `${this.apiUrl}/${path}?q=${encodeURIComponent(query)}&limit=20`; + const res = await this.http.get(url, { + headers: { Accept: 'application/json' }, + signal: opts.signal, + }); + if (res.status !== 200) throw new Error(`Jikan search failed: ${res.status}`); + const json = (await res.json()) as any; + return ((json?.data as any[]) ?? []).map((m) => mapNode(m, path, this.id)); + } + + async info(id: string, opts: SourceCallOpts): Promise { + const { r: raw } = decodeId(id); + const sep = raw.indexOf(':'); + let path: 'anime' | 'manga' = 'anime'; + let numericId: number; + if (sep >= 0 && (raw.slice(0, sep) === 'anime' || raw.slice(0, sep) === 'manga')) { + path = raw.slice(0, sep) as 'anime' | 'manga'; + numericId = Number(raw.slice(sep + 1)); + } else { + numericId = Number(raw); + } + const res = await this.http.get(`${this.apiUrl}/${path}/${numericId}/full`, { + headers: { Accept: 'application/json' }, + signal: opts.signal, + }); + if (res.status !== 200) throw new Error(`Jikan info failed: ${res.status}`); + const json = (await res.json()) as any; + const m = json?.data; + if (!m) throw new Error(`MAL: no media for id ${raw}`); + return mapNode(m, path, this.id); + } + + async browse( + opts: SourceCallOpts & { + list: 'trending' | 'popular' | 'seasonal' | 'top'; + kind: 'anime' | 'manga'; + page?: number; + perPage?: number; + season?: string; + year?: number; + }, + ): Promise> { + const path = opts.kind === 'manga' ? 'manga' : 'anime'; + const page = opts.page ?? 1; + const perPage = Math.min(opts.perPage ?? 20, 25); + let url: string; + if (opts.list === 'seasonal') { + if (!opts.season || !opts.year) + throw new Error('Jikan browse(seasonal): season and year required'); + url = `${this.apiUrl}/seasons/${opts.year}/${opts.season.toLowerCase()}?page=${page}&limit=${perPage}`; + } else if (opts.list === 'top') { + url = `${this.apiUrl}/top/${path}?page=${page}&limit=${perPage}`; + } else { + url = `${this.apiUrl}/top/${path}?filter=bypopularity&page=${page}&limit=${perPage}`; + } + const res = await this.http.get(url, { + headers: { Accept: 'application/json' }, + signal: opts.signal, + }); + if (res.status !== 200) throw new Error(`Jikan browse failed: ${res.status}`); + const json = (await res.json()) as any; + const items = ((json?.data as any[]) ?? []).map((m) => mapNode(m, path, this.id)); + return { items }; + } +} diff --git a/src/sources/mangadex.ts b/src/sources/mangadex.ts new file mode 100644 index 0000000..e3c8cbf --- /dev/null +++ b/src/sources/mangadex.ts @@ -0,0 +1,99 @@ +import { HttpClient } from '../internal/http.js'; +import { encodeId, decodeId } from '../internal/id.js'; +import type { Media, Chapter, Pages, List } from '../types.js'; +import type { Source, SourceCallOpts } from './base.js'; + +const MANGADEX_API = 'https://api.mangadex.org'; +const COVER_BASE = 'https://uploads.mangadex.org/covers'; + +export class MangadexSource implements Source { + readonly id = 'mangadex'; + readonly kinds = ['manga'] as const; + readonly caps = { search: true, chapters: true, pages: true, mapping: true } as const; + readonly malsyncSites = ['MangaDex', 'Mangadex'] as const; + + constructor(private http: HttpClient) {} + + async search(query: string, _kind: 'anime' | 'manga', opts: SourceCallOpts): Promise { + const url = `${MANGADEX_API}/manga?title=${encodeURIComponent(query)}&includes[]=cover_art&limit=24&contentRating[]=safe&contentRating[]=suggestive&hasAvailableChapters=true`; + const res = await this.http.get(url, { signal: opts.signal }); + const data = (await res.json()) as any; + return ((data.data as any[]) ?? []).map((manga): Media => { + const title = manga.attributes.title.en || Object.values(manga.attributes.title)[0]; + const coverRel = manga.relationships.find((r: any) => r.type === 'cover_art'); + const coverFileName = coverRel?.attributes?.fileName; + return { + id: encodeId({ t: 'media', s: this.id, r: manga.id }), + kind: 'manga', + title: { preferred: String(title) }, + cover: coverFileName + ? { url: `${COVER_BASE}/${manga.id}/${coverFileName}.256.jpg` } + : undefined, + year: typeof manga.attributes.year === 'number' ? manga.attributes.year : undefined, + catalogues: [this.id], + playbackSources: [this.id], + mappings: { sources: { [this.id]: manga.id } }, + }; + }); + } + + async chapters( + mediaId: string, + opts: SourceCallOpts & { cursor?: string; limit?: number }, + ): Promise> { + const items: Chapter[] = []; + let offset = 0; + const limit = 500; + let total = 0; + do { + const url = `${MANGADEX_API}/manga/${mediaId}/feed?limit=${limit}&offset=${offset}&order[chapter]=asc&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic&includeExternalUrl=0`; + const res = await this.http.get(url, { signal: opts.signal }); + const data = (await res.json()) as any; + total = data.total; + for (const ch of data.data as any[]) { + const num = parseFloat(ch.attributes.chapter); + items.push({ + id: encodeId({ t: 'chapter', s: this.id, r: ch.id }), + mediaId: encodeId({ t: 'media', s: this.id, r: mediaId }), + number: isNaN(num) ? 0 : num, + title: ch.attributes.title + ? `Ch. ${ch.attributes.chapter} - ${ch.attributes.title}` + : `Chapter ${ch.attributes.chapter}`, + source: this.id, + }); + } + offset += limit; + } while (offset < total); + return { items }; + } + + async pages(chapterId: string, opts: SourceCallOpts): Promise { + const { r: rawId } = decodeId(chapterId); + const url = `${MANGADEX_API}/at-home/server/${rawId}`; + const res = await this.http.get(url, { signal: opts.signal }); + const data = (await res.json()) as any; + const base = data.baseUrl; + const hash = data.chapter.hash; + const pages = (data.chapter.data as string[]).map((file) => ({ + url: `${base}/data/${hash}/${file}`, + origin: { host: 'uploads.mangadex.org' }, + })); + return { pages, adjacent: {} }; + } + + async lookupByMapping( + mappings: Record, + opts?: SourceCallOpts, + ): Promise { + const mal = mappings.mal; + if (!mal) return null; + const url = `${MANGADEX_API}/manga?ids[]=${String(mal)}&contentRating[]=safe`; + try { + const res = await this.http.get(url, { signal: opts?.signal }); + const data = (await res.json()) as any; + return data.data?.[0]?.id ?? null; + } catch { + return null; + } + } +} diff --git a/src/sources/mangapill.ts b/src/sources/mangapill.ts new file mode 100644 index 0000000..137b83c --- /dev/null +++ b/src/sources/mangapill.ts @@ -0,0 +1,108 @@ +import { HttpClient } from '../internal/http.js'; +import { DomRegistry } from '../internal/dom.js'; +import { encodeId, decodeId } from '../internal/id.js'; +import type { Media, Chapter, Pages, List } from '../types.js'; +import type { Source, SourceCallOpts } from './base.js'; + +const BASE = 'https://mangapill.com'; +const HEADERS = { + Referer: BASE, + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + Connection: 'keep-alive', + 'Cache-Control': 'max-age=604800', +}; + +export class MangapillSource implements Source { + readonly id = 'mangapill'; + readonly kinds = ['manga'] as const; + readonly caps = { search: true, chapters: true, pages: true, mapping: true } as const; + readonly malsyncSites = ['Mangapill'] as const; + + constructor(private http: HttpClient) {} + + async search(query: string, _kind: 'anime' | 'manga', opts: SourceCallOpts): Promise { + const res = await this.http.get(`${BASE}/search?q=${encodeURIComponent(query)}`, { + signal: opts.signal, + headers: { ...HEADERS, Accept: 'text/html,application/xhtml+xml' }, + }); + const doc = DomRegistry.parse(`
${await res.text()}
`); + const out: Media[] = []; + for (const item of doc.querySelectorAll('div.grid > div')) { + const a = item.querySelector('a.mb-2'); + const titleEl = a?.querySelector('div'); + if (!a || !titleEl) continue; + const href = a.getAttribute('href'); + const title = titleEl.textContent?.trim(); + const img = item.querySelector('img'); + const coverUrl = img?.getAttribute('data-src') || img?.getAttribute('src'); + if (href && title) { + const rawId = href.startsWith('/') ? href.slice(1) : href; + out.push({ + id: encodeId({ t: 'media', s: this.id, r: rawId }), + kind: 'manga', + title: { preferred: title }, + cover: coverUrl ? { url: coverUrl } : undefined, + catalogues: [this.id], + playbackSources: [this.id], + mappings: { sources: { [this.id]: rawId } }, + }); + } + } + return out; + } + + async chapters( + mediaId: string, + opts: SourceCallOpts & { cursor?: string; limit?: number }, + ): Promise> { + const res = await this.http.get(`${BASE}/${mediaId}`, { + signal: opts.signal, + headers: HEADERS, + }); + const doc = DomRegistry.parse(`
${await res.text()}
`); + const items: Chapter[] = []; + for (const item of doc.querySelectorAll('a.border')) { + const href = item.getAttribute('href'); + if (!href || !href.includes('/chapters/')) continue; + const title = item.textContent?.trim() || ''; + let num = 0; + const m = title.match(/Chapter\s+(\d+(\.\d+)?)/i); + if (m) num = parseFloat(m[1]); + const rawId = href.startsWith('/') ? href.slice(1) : href; + items.push({ + id: encodeId({ t: 'chapter', s: this.id, r: rawId }), + mediaId: encodeId({ t: 'media', s: this.id, r: mediaId }), + number: num, + title, + source: this.id, + }); + } + items.reverse(); + return { items }; + } + + async pages(chapterId: string, opts: SourceCallOpts): Promise { + const { r: rawId } = decodeId(chapterId); + const res = await this.http.get(`${BASE}/${rawId}`, { + signal: opts.signal, + headers: HEADERS, + }); + const doc = DomRegistry.parse(`
${await res.text()}
`); + const pages = doc + .querySelectorAll('.js-page') + .map((img) => { + const src = img.getAttribute('data-src') || img.getAttribute('src'); + return src ? { url: src, origin: { host: 'mangapill.com' } } : null; + }) + .filter((p): p is NonNullable => p !== null); + return { pages, adjacent: {} }; + } + + async lookupByMapping( + _mappings: Record, + _opts?: SourceCallOpts, + ): Promise { + return null; + } +} diff --git a/src/sources/megaplay.ts b/src/sources/megaplay.ts new file mode 100644 index 0000000..17c5931 --- /dev/null +++ b/src/sources/megaplay.ts @@ -0,0 +1,142 @@ +import { HttpClient } from '../internal/http.js'; +import { encodeId, decodeId } from '../internal/id.js'; +import type { Media, Episode, Stream, List, Subtitle } from '../types.js'; +import type { Source, SourceCallOpts } from './base.js'; + +const ANILIST_API = 'https://graphql.anilist.co'; + +export class MegaPlaySource implements Source { + readonly id = 'megaplay'; + readonly kinds = ['anime'] as const; + readonly caps = { search: true, episodes: true, stream: true, mapping: true } as const; + + private baseUrl: string; + + constructor( + private http: HttpClient, + baseUrl = 'https://megaplay.buzz', + ) { + this.baseUrl = baseUrl; + if (!http.getDefaultHeaders()['User-Agent']) { + http.setUserAgent( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + ); + } + } + + async search(query: string, _kind: 'anime' | 'manga', opts: SourceCallOpts): Promise { + const gql = `query($search:String){Page(page:1,perPage:15){media(search:$search,type:ANIME){id title{romaji english}coverImage{large}episodes}}}`; + const res = await this.http.post( + ANILIST_API, + { query: gql, variables: { search: query } }, + { signal: opts.signal }, + ); + const json = (await res.json()) as any; + return ((json.data?.Page?.media as any[]) ?? []).map( + (m): Media => ({ + id: encodeId({ t: 'media', s: this.id, r: String(m.id), m: { al: m.id } }), + kind: 'anime', + title: { preferred: m.title.english ?? m.title.romaji ?? '' }, + cover: m.coverImage?.large ? { url: m.coverImage.large } : undefined, + catalogues: [this.id], + playbackSources: [this.id], + mappings: { anilist: m.id, sources: { [this.id]: String(m.id) } }, + }), + ); + } + + async episodes( + mediaId: string, + opts: SourceCallOpts & { cursor?: string; limit?: number }, + ): Promise> { + const gql = `query($id:Int){Media(id:$id){episodes}}`; + const res = await this.http.post( + ANILIST_API, + { query: gql, variables: { id: parseInt(mediaId) } }, + { signal: opts.signal }, + ); + const json = (await res.json()) as any; + const count: number = json.data?.Media?.episodes ?? 1; + const items: Episode[] = []; + for (let i = 1; i <= count; i++) { + items.push({ + id: encodeId({ t: 'episode', s: this.id, r: `${mediaId}:${i}` }), + mediaId: encodeId({ t: 'media', s: this.id, r: mediaId }), + number: i, + title: `Episode ${i}`, + languages: ['sub', 'dub'], + qualities: ['auto'], + source: this.id, + }); + } + return { items }; + } + + async stream( + episodeId: string, + opts: SourceCallOpts & { language?: 'sub' | 'dub' | 'raw' }, + ): Promise { + const { r: rawUnit } = decodeId(episodeId); + const [aniId, epNum] = rawUnit.split(':'); + const lang = opts.language ?? 'sub'; + const embedUrl = `${this.baseUrl}/stream/ani/${aniId}/${epNum}/${lang}`; + + const embedRes = await this.http.get(embedUrl, { + signal: opts.signal, + headers: { Referer: this.baseUrl }, + }); + const embedPage = await embedRes.text(); + if (embedPage.includes('Error - MegaPlay')) { + throw new Error(`MegaPlay: no mapping for AniList ID ${aniId} episode ${epNum} (${lang})`); + } + const fileIdMatch = embedPage.match(/File\s+(\d+)\s+-/); + if (!fileIdMatch) throw new Error('MegaPlay: no file ID on embed page'); + const fileId = fileIdMatch[1]; + + const srcRes = await this.http.get(`${this.baseUrl}/stream/getSources?id=${fileId}`, { + signal: opts.signal, + headers: { + Referer: `${this.baseUrl}/stream/ani/${aniId}/${epNum}/${lang}`, + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + const srcJson = (await srcRes.json()) as any; + if (!srcJson.sources?.file) throw new Error('MegaPlay: no video sources in response'); + + const url: string = srcJson.sources.file; + let host = ''; + try { + host = new URL(url).hostname; + } catch {} + + const subtitles: Subtitle[] = ((srcJson.tracks ?? []) as any[]) + .filter((t) => t.kind === 'captions') + .map( + (t): Subtitle => ({ + url: t.file, + label: t.label, + language: String(t.label).toLowerCase(), + format: String(t.file).endsWith('.vtt') ? 'vtt' : 'srt', + }), + ); + + return { + url, + origin: { host, url, proxied: false }, + isHls: url.includes('.m3u8'), + qualities: [{ label: 'auto', url }], + language: lang, + subtitles, + headers: { Referer: `${this.baseUrl}/` }, + adjacent: {}, + }; + } + + async lookupByMapping( + mappings: Record, + _opts?: SourceCallOpts, + ): Promise { + const al = mappings.anilist ?? (mappings as any).al; + return al != null ? String(al) : null; + } +} diff --git a/src/sources/weebcentral.ts b/src/sources/weebcentral.ts new file mode 100644 index 0000000..47257b7 --- /dev/null +++ b/src/sources/weebcentral.ts @@ -0,0 +1,105 @@ +import { HttpClient } from '../internal/http.js'; +import { DomRegistry } from '../internal/dom.js'; +import { encodeId, decodeId } from '../internal/id.js'; +import type { Media, Chapter, Pages, List } from '../types.js'; +import type { Source, SourceCallOpts } from './base.js'; + +const BASE = 'https://weebcentral.com/'; +const HEADERS = { + Referer: 'https://google.com', + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + Connection: 'keep-alive', + 'Cache-Control': 'max-age=604800', +}; + +export class WeebcentralSource implements Source { + readonly id = 'weebcentral'; + readonly kinds = ['manga'] as const; + readonly caps = { search: true, chapters: true, pages: true, mapping: true } as const; + readonly malsyncSites = ['Weebcentral', 'WeebCentral'] as const; + + constructor(private http: HttpClient) {} + + async search(query: string, _kind: 'anime' | 'manga', opts: SourceCallOpts): Promise { + const url = `${BASE}search/data?text=${encodeURIComponent(query)}&limit=24&offset=0&sort=Best+Match&order=Descending&official=Any&anime=Any&adult=Any&display_mode=Full+Display`; + const res = await this.http.get(url, { signal: opts.signal, headers: HEADERS }); + const doc = DomRegistry.parse(`
${await res.text()}
`); + const out: Media[] = []; + for (const item of doc.querySelectorAll('article.bg-base-300')) { + const a = item.querySelector('a.line-clamp-1'); + if (!a) continue; + const href = a.getAttribute('href'); + const title = a.textContent?.trim(); + const coverUrl = item.querySelector('source')?.getAttribute('srcset') ?? undefined; + if (href && title) { + const idMatch = href.match(/series\/([A-Z0-9]+)/i); + if (idMatch) { + out.push({ + id: encodeId({ t: 'media', s: this.id, r: idMatch[1] }), + kind: 'manga', + title: { preferred: title }, + cover: coverUrl ? { url: coverUrl } : undefined, + catalogues: [this.id], + playbackSources: [this.id], + mappings: { sources: { [this.id]: idMatch[1] } }, + }); + } + } + } + return out; + } + + async chapters( + mediaId: string, + opts: SourceCallOpts & { cursor?: string; limit?: number }, + ): Promise> { + const url = `${BASE}series/${mediaId}/full-chapter-list`; + const res = await this.http.get(url, { signal: opts.signal, headers: HEADERS }); + const doc = DomRegistry.parse(`
${await res.text()}
`); + const items: Chapter[] = []; + for (const item of doc.querySelectorAll('div > a')) { + const href = item.getAttribute('href'); + if (!href || !href.includes('/chapters/')) continue; + const titleEl = item.querySelector('span.grow.flex.items-center.gap-2 span'); + const title = titleEl?.textContent?.trim() || ''; + let num = 0; + const m = title.match(/Chapter\s+(\d+(\.\d+)?)/i) || title.match(/(\d+(\.\d+)?)/); + if (m) num = parseFloat(m[1]); + const idMatch = href.match(/chapters\/([A-Z0-9]+)/i); + if (idMatch) { + items.push({ + id: encodeId({ t: 'chapter', s: this.id, r: idMatch[1] }), + mediaId: encodeId({ t: 'media', s: this.id, r: mediaId }), + number: num, + title, + source: this.id, + }); + } + } + items.reverse(); + return { items }; + } + + async pages(chapterId: string, opts: SourceCallOpts): Promise { + const { r: rawId } = decodeId(chapterId); + const url = `${BASE}chapters/${rawId}/images?is_prev=False¤t_page=1&reading_style=long_strip`; + const res = await this.http.get(url, { signal: opts.signal, headers: HEADERS }); + const doc = DomRegistry.parse(`
${await res.text()}
`); + const pages = doc + .querySelectorAll('img') + .map((img) => { + const src = img.getAttribute('src') ?? ''; + return src ? { url: src, origin: { host: 'weebcentral.com' } } : null; + }) + .filter((p): p is NonNullable => p !== null); + return { pages, adjacent: {} }; + } + + async lookupByMapping( + _mappings: Record, + _opts?: SourceCallOpts, + ): Promise { + return null; + } +} diff --git a/tests/e2e/allmanga.test.ts b/tests/e2e/allmanga.test.ts index 527265a..ac08edd 100644 --- a/tests/e2e/allmanga.test.ts +++ b/tests/e2e/allmanga.test.ts @@ -1,51 +1,43 @@ /** - * E2E integration tests for AllmangaProvider. - * - * To run: npx vitest run tests/e2e/allmanga.test.ts + * E2E integration tests for AllmangaSource. */ import { describe, it, expect } from 'vitest'; -import { HttpClient } from '../../src/transport/http.js'; -import { AllmangaProvider } from '../../src/providers/AllmangaProvider.js'; -import { captureStreamScreenshot } from './screenshotHelper.js'; +import { HttpClient } from '../../src/internal/http.js'; +import { AllmangaSource } from '../../src/sources/allmanga.js'; +import { decodeId } from '../../src/internal/id.js'; +import { captureStreamScreenshot, streamToPayload } from './screenshotHelper.js'; describe('AllManga E2E', () => { it('searches, fetches episodes, resolves a stream, and captures a screenshot', async () => { const http = new HttpClient({ timeoutMs: 25000 }); - const provider = new AllmangaProvider(http); + const source = new AllmangaSource(http); - // Pick a long-running show with a robust source mix. const query = 'Frieren'; - const searchResults = await provider.search(query); - expect(searchResults.length).toBeGreaterThan(0); + const results = await source.search(query, 'anime', {}); + expect(results.length).toBeGreaterThan(0); - // Prefer the mainline "Beyond Journey's End" so we don't end up on a - // promo short with no sources. const target = - searchResults.find( + results.find( (r) => - r.title.toLowerCase().includes("beyond journey's end") && - !r.title.toLowerCase().includes('mini'), - ) ?? searchResults[0]; + r.title.preferred.toLowerCase().includes("beyond journey's end") && + !r.title.preferred.toLowerCase().includes('mini'), + ) ?? results[0]; - expect(target.providerId).toBe('allmanga'); - console.log(`AllManga selected: ${target.title} (${target.id})`); + const decoded = decodeId(target.id); + expect(decoded.s).toBe('allmanga'); + console.log(`AllManga selected: ${target.title.preferred} (${decoded.r})`); - const units = await provider.fetchContentUnits(target.id, 'sub'); - expect(units.length).toBeGreaterThan(0); + const mediaId = decoded.r; + const list = await source.episodes(mediaId, {}); + expect(list.items.length).toBeGreaterThan(0); - const ep1 = units[0]; - const stream = await provider.resolveStream(ep1.id); - expect(stream.type).toBe('video'); - if (stream.type !== 'video') return; + const ep1 = list.items[0]; + const stream = await source.stream(ep1.id, { language: 'sub' }); + expect(stream.url).toBeTruthy(); + expect(stream.isHls !== undefined).toBe(true); + console.log(`AllManga stream: ${stream.url.slice(0, 80)}`); - expect(stream.streams.length).toBeGreaterThan(0); - console.log( - `AllManga resolved ${stream.streams.length} stream candidate(s); ` + - `top: ${stream.streams[0].sourceUrl.slice(0, 80)}`, - ); - - // Iterate candidates until one yields a real screenshot. - const result = await captureStreamScreenshot('allmanga', stream.streams); + const result = await captureStreamScreenshot('allmanga', streamToPayload(stream)); expect(result.outputPath).toMatch(/screenshot_allmanga\.png$/); }, 90000); }); diff --git a/tests/e2e/anikoto.test.ts b/tests/e2e/anikoto.test.ts index d2e6c80..28d5051 100644 --- a/tests/e2e/anikoto.test.ts +++ b/tests/e2e/anikoto.test.ts @@ -1,71 +1,53 @@ /** - * E2E integration tests for AnikotoProvider (anikototv.to backend). - * - * To run: npx vitest run tests/e2e/anikoto.test.ts + * E2E integration tests for AnikotoSource (anikototv.to backend). */ import { describe, it, expect } from 'vitest'; -import { HttpClient } from '../../src/transport/http.js'; -import { AnikotoProvider } from '../../src/providers/AnikotoProvider.js'; -import { captureStreamScreenshot } from './screenshotHelper.js'; +import { HttpClient } from '../../src/internal/http.js'; +import { AnikotoSource } from '../../src/sources/anikoto.js'; +import { decodeId } from '../../src/internal/id.js'; +import { captureStreamScreenshot, streamToPayload } from './screenshotHelper.js'; describe('Anikoto E2E', () => { it('searches, fetches episodes, resolves a sub stream, and captures a screenshot', async () => { const http = new HttpClient({ timeoutMs: 25000 }); - const provider = new AnikotoProvider(http); + const source = new AnikotoSource(http); - const query = 'Solo Leveling'; - const searchResults = await provider.search(query); - expect(searchResults.length).toBeGreaterThan(0); + const results = await source.search('Solo Leveling', 'anime', {}); + expect(results.length).toBeGreaterThan(0); - const target = searchResults[0]; - expect(target.providerId).toBe('anikoto'); - console.log(`Anikoto selected: ${target.title} (${target.id})`); + const target = results[0]; + const decoded = decodeId(target.id); + expect(decoded.s).toBe('anikoto'); + console.log(`Anikoto selected: ${target.title.preferred} (${decoded.r})`); - const units = await provider.fetchContentUnits(target.id); - expect(units.length).toBeGreaterThan(0); + const list = await source.episodes(decoded.r, {}); + expect(list.items.length).toBeGreaterThan(0); - const ep1 = units[0]; - const stream = await provider.resolveStream(ep1.id, 'sub'); - expect(stream.type).toBe('video'); - if (stream.type !== 'video') return; - expect(stream.streams.length).toBeGreaterThan(0); + const stream = await source.stream(list.items[0].id, { language: 'sub' }); + expect(stream.url).toBeTruthy(); + console.log(`Anikoto (sub) stream: ${stream.url.slice(0, 80)}`); - console.log( - `Anikoto (sub) resolved ${stream.streams.length} stream candidate(s); ` + - `top: ${stream.streams[0].sourceUrl.slice(0, 80)}`, - ); - - const result = await captureStreamScreenshot('anikoto_sub', stream.streams); + const result = await captureStreamScreenshot('anikoto_sub', streamToPayload(stream)); expect(result.outputPath).toMatch(/screenshot_anikoto_sub\.png$/); }, 90000); - it('resolves a dub stream, and captures a screenshot', async () => { + it('resolves a dub stream for known ID, and captures a screenshot', async () => { const http = new HttpClient({ timeoutMs: 25000 }); - const provider = new AnikotoProvider(http); + const source = new AnikotoSource(http); - // Using a known ID for Solo Leveling to save time - const targetId = '7457'; - const units = await provider.fetchContentUnits(targetId); - expect(units.length).toBeGreaterThan(0); + const list = await source.episodes('7457', {}); + expect(list.items.length).toBeGreaterThan(0); - const ep1 = units[0]; - // Check if dub is available for this episode - if (!ep1.availableLanguages.includes('dub')) { - console.warn('Dub not available for this episode, skipping dub test'); + const dubEp = list.items.find((ep) => ep.languages.includes('dub')); + if (!dubEp) { + console.warn('Dub not available for this title, skipping dub test'); return; } - const stream = await provider.resolveStream(ep1.id, 'dub'); - expect(stream.type).toBe('video'); - if (stream.type !== 'video') return; - expect(stream.streams.length).toBeGreaterThan(0); - - console.log( - `Anikoto (dub) resolved ${stream.streams.length} stream candidate(s); ` + - `top: ${stream.streams[0].sourceUrl.slice(0, 80)}`, - ); + const stream = await source.stream(dubEp.id, { language: 'dub' }); + expect(stream.url).toBeTruthy(); - const result = await captureStreamScreenshot('anikoto_dub', stream.streams); + const result = await captureStreamScreenshot('anikoto_dub', streamToPayload(stream)); expect(result.outputPath).toMatch(/screenshot_anikoto_dub\.png$/); }, 90000); }); diff --git a/tests/e2e/anilistMeta.test.ts b/tests/e2e/anilistMeta.test.ts deleted file mode 100644 index 8b756e9..0000000 --- a/tests/e2e/anilistMeta.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Live E2E for AnilistMeta — hits the public graphql.anilist.co endpoint. - * - * Validates the full mapping (and enrichments: relations, characters, - * staff, recommendations, externalLinks, streamingEpisodes) against a - * stable, well-known title (Cowboy Bebop, AniList ID 1 — first entry, - * finished airing in 1999, will never disappear). - */ -import { describe, it, expect } from 'vitest'; -import { HttpClient } from '../../src/transport/http.js'; -import { AnilistMeta } from '../../src/meta/AnilistMeta.js'; - -describe('AnilistMeta — live', () => { - it('search returns AniList-prefixed URNs for a known query', async () => { - const http = new HttpClient({ timeoutMs: 20_000 }); - const meta = new AnilistMeta(http); - const results = await meta.search('Cowboy Bebop'); - expect(results.length).toBeGreaterThan(0); - const hit = results.find((r) => r.title.english?.toLowerCase() === 'cowboy bebop'); - expect(hit).toBeDefined(); - expect(hit!.id.startsWith('anilist:')).toBe(true); - expect(hit!.providerId).toBe('anilist'); - expect(hit!.catalogType).toBe('ANIME'); - }, 30_000); - - it('fetchMediaInfo for AniList ID 1 (Cowboy Bebop) populates all enrichment fields', async () => { - const http = new HttpClient({ timeoutMs: 25_000 }); - const meta = new AnilistMeta(http); - const info = await meta.fetchMediaInfo('anilist:1'); - - expect(info.id).toBe('anilist:1'); - expect(info.providerId).toBe('anilist'); - expect(info.catalogType).toBe('ANIME'); - expect(info.title.english).toBe('Cowboy Bebop'); - expect(info.title.romaji).toBe('Cowboy Bebop'); - expect(info.status).toBe('FINISHED'); - expect(info.format).toBe('TV'); - expect(info.episodeCount).toBe(26); - expect(info.year).toBe(1998); - expect(info.season).toBe('SPRING'); - expect(info.startDate).toBe('1998-04-03'); - expect(info.mappings?.anilist).toBe(1); - expect(typeof info.mappings?.mal).toBe('number'); - expect(info.cover?.large).toMatch(/^https?:\/\//); - expect(typeof info.score).toBe('number'); - expect((info.genres ?? []).length).toBeGreaterThan(0); - expect((info.studios ?? []).length).toBeGreaterThan(0); - - // Enrichments — AniList ships all of these for Cowboy Bebop. - expect((info.characters ?? []).length).toBeGreaterThan(0); - expect(info.characters?.[0].id.startsWith('anilist:character:')).toBe(true); - expect((info.staff ?? []).length).toBeGreaterThan(0); - expect(info.staff?.[0].id.startsWith('anilist:staff:')).toBe(true); - expect((info.recommendations ?? []).length).toBeGreaterThan(0); - expect(info.recommendations?.[0].id.startsWith('anilist:')).toBe(true); - expect((info.externalLinks ?? []).length).toBeGreaterThan(0); - }, 40_000); - - it('browse(trending) returns at least one anime', async () => { - const http = new HttpClient({ timeoutMs: 20_000 }); - const meta = new AnilistMeta(http); - expect(meta.supportsBrowseKind('trending')).toBe(true); - const items = await meta.browse('trending', { catalogType: 'ANIME', perPage: 5 }); - expect(items.length).toBeGreaterThan(0); - expect(items[0].id.startsWith('anilist:')).toBe(true); - }, 30_000); - - it('browse(seasonal) requires season+year', async () => { - const http = new HttpClient({ timeoutMs: 5_000 }); - const meta = new AnilistMeta(http); - await expect(meta.browse('seasonal', {})).rejects.toThrow(/season and year/); - }); - - it('rejects non-numeric AniList IDs without making a network call', async () => { - const http = new HttpClient({ timeoutMs: 5_000 }); - const meta = new AnilistMeta(http); - await expect(meta.fetchMediaInfo('anilist:not-a-number')).rejects.toThrow(/Invalid AniList ID/); - }); -}); diff --git a/tests/e2e/animeparadise.test.ts b/tests/e2e/animeparadise.test.ts index 27fad3e..d5ec868 100644 --- a/tests/e2e/animeparadise.test.ts +++ b/tests/e2e/animeparadise.test.ts @@ -1,39 +1,35 @@ /** - * E2E integration tests for AnimeParadiseProvider. - * - * To run: npx vitest run tests/e2e/animeparadise.test.ts + * E2E integration tests for AnimeParadiseSource. */ import { describe, it, expect } from 'vitest'; -import { HttpClient } from '../../src/transport/http.js'; -import { AnimeParadiseProvider } from '../../src/providers/AnimeParadiseProvider.js'; -import { captureStreamScreenshot } from './screenshotHelper.js'; +import { HttpClient } from '../../src/internal/http.js'; +import { AnimeParadiseSource } from '../../src/sources/animeparadise.js'; +import { decodeId } from '../../src/internal/id.js'; +import { captureStreamScreenshot, streamToPayload } from './screenshotHelper.js'; describe('AnimeParadise E2E', () => { it('searches, fetches episodes, resolves a stream, and captures a screenshot', async () => { const http = new HttpClient({ timeoutMs: 25000 }); - const provider = new AnimeParadiseProvider(http); + const source = new AnimeParadiseSource(http); - const searchResults = await provider.search('Frieren'); - expect(searchResults.length).toBeGreaterThan(0); + const results = await source.search('Frieren', 'anime', {}); + expect(results.length).toBeGreaterThan(0); const target = - searchResults.find((r) => !r.title.toLowerCase().includes('season 2')) ?? searchResults[0]; + results.find((r) => !r.title.preferred.toLowerCase().includes('season 2')) ?? results[0]; - expect(target.providerId).toBe('animeparadise'); - console.log(`AnimeParadise selected: ${target.title} (${target.id})`); + const decoded = decodeId(target.id); + expect(decoded.s).toBe('animeparadise'); + console.log(`AnimeParadise selected: ${target.title.preferred} (${decoded.r})`); - const units = await provider.fetchContentUnits(target.id); - expect(units.length).toBeGreaterThan(0); + const list = await source.episodes(decoded.r, {}); + expect(list.items.length).toBeGreaterThan(0); - const ep1 = units[0]; - const stream = await provider.resolveStream(ep1.id); - expect(stream.type).toBe('video'); - if (stream.type !== 'video') return; + const stream = await source.stream(list.items[0].id, {}); + expect(stream.url).toBeTruthy(); + console.log(`AnimeParadise stream: ${stream.url.slice(0, 80)}`); - expect(stream.streams.length).toBeGreaterThan(0); - console.log(`AnimeParadise resolved stream: ${stream.streams[0].sourceUrl.slice(0, 80)}`); - - const result = await captureStreamScreenshot('animeparadise', stream.streams); + const result = await captureStreamScreenshot('animeparadise', streamToPayload(stream)); expect(result.outputPath).toMatch(/screenshot_animeparadise\.png$/); }, 90000); }); diff --git a/tests/e2e/baseMetadataProvider.test.ts b/tests/e2e/baseMetadataProvider.test.ts deleted file mode 100644 index 1f2537f..0000000 --- a/tests/e2e/baseMetadataProvider.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Live integration test for BaseMetadataProvider edge cases: - * - strict episode matching - * - closest-below fallback - * - absolute-episode offset computation via real PREQUEL relations - * - * Uses AnilistMeta + AllmangaProvider only (no mocks, no fixtures). - */ -import { describe, expect, it } from 'vitest'; -import { HttpClient } from '../../src/transport/http.js'; -import { AnilistMeta } from '../../src/meta/AnilistMeta.js'; -import { AllmangaProvider } from '../../src/providers/AllmangaProvider.js'; - -describe('BaseMetadataProvider — live', () => { - it('strictEpisodeMatching throws on an impossible episode number', async () => { - const http = new HttpClient({ timeoutMs: 30_000 }); - const meta = new AnilistMeta(http); - const allmanga = new AllmangaProvider(http); - // Cowboy Bebop has 26 episodes; request ep 999. - await expect( - meta.resolveStream('anilist:1', 999, allmanga, undefined, { - strictEpisodeMatching: true, - }), - ).rejects.toThrow(/Episode 999 not found/); - }, 90_000); - - it('computeAbsoluteEpisodeOffset traverses PREQUEL relations', async () => { - const http = new HttpClient({ timeoutMs: 30_000 }); - const meta = new AnilistMeta(http); - // Attack on Titan Final Season (AniList id 110277) — its PREQUEL chain - // climbs back through Season 3 Part 2 → Season 3 → Season 2 → Season 1. - // Each season's episodeCount is a stable known value. - const offset = await meta.computeAbsoluteEpisodeOffset('anilist:110277'); - // The exact sum varies as AniList updates the chain, but the - // PREQUEL graph is well-established: it should be > 50 (S1=25 + S2=12 - // + S3 splits = ~22 = 59). Use a generous lower bound. - expect(offset).toBeGreaterThan(50); - }, 60_000); - - it('supportsBrowseKind reports the implemented buckets', async () => { - const http = new HttpClient({ timeoutMs: 5_000 }); - const meta = new AnilistMeta(http); - expect(meta.supportsBrowseKind('trending')).toBe(true); - expect(meta.supportsBrowseKind('popular')).toBe(true); - expect(meta.supportsBrowseKind('seasonal')).toBe(true); - expect(meta.supportsBrowseKind('top')).toBe(true); - }); -}); diff --git a/tests/e2e/gogoanime.test.ts b/tests/e2e/gogoanime.test.ts index 0dff7e0..9abff1c 100644 --- a/tests/e2e/gogoanime.test.ts +++ b/tests/e2e/gogoanime.test.ts @@ -1,41 +1,34 @@ /** - * E2E integration tests for GogoanimeProvider (anineko.to backend). - * - * To run: npx vitest run tests/e2e/gogoanime.test.ts + * E2E integration tests for GogoanimeSource (anineko.to backend). */ import { describe, it, expect } from 'vitest'; -import { HttpClient } from '../../src/transport/http.js'; -import { GogoanimeProvider } from '../../src/providers/GogoanimeProvider.js'; -import { captureStreamScreenshot } from './screenshotHelper.js'; +import { HttpClient } from '../../src/internal/http.js'; +import { GogoanimeSource } from '../../src/sources/gogoanime.js'; +import { decodeId } from '../../src/internal/id.js'; +import { captureStreamScreenshot, streamToPayload } from './screenshotHelper.js'; describe('GogoAnime E2E', () => { it('searches, fetches episodes, resolves a stream, and captures a screenshot', async () => { const http = new HttpClient({ timeoutMs: 25000 }); - const provider = new GogoanimeProvider(http); + const source = new GogoanimeSource(http); - const query = 'Frieren'; - const searchResults = await provider.search(query); - expect(searchResults.length).toBeGreaterThan(0); + const results = await source.search('Frieren', 'anime', {}); + expect(results.length).toBeGreaterThan(0); - const target = searchResults[0]; - expect(target.providerId).toBe('gogoanime'); - console.log(`GogoAnime selected: ${target.title} (${target.id})`); + const target = results[0]; + const decoded = decodeId(target.id); + expect(decoded.s).toBe('gogoanime'); + console.log(`GogoAnime selected: ${target.title.preferred} (${decoded.r})`); - const units = await provider.fetchContentUnits(target.id); - expect(units.length).toBeGreaterThan(0); + const list = await source.episodes(decoded.r, {}); + expect(list.items.length).toBeGreaterThan(0); - const ep1 = units[0]; - const stream = await provider.resolveStream(ep1.id); - expect(stream.type).toBe('video'); - if (stream.type !== 'video') return; - expect(stream.streams.length).toBeGreaterThan(0); + const epDecoded = decodeId(list.items[0].id); + const stream = await source.stream(list.items[0].id, {}); + expect(stream.url).toBeTruthy(); + console.log(`GogoAnime stream: ${stream.url.slice(0, 80)}`); - console.log( - `GogoAnime resolved ${stream.streams.length} stream candidate(s); ` + - `top: ${stream.streams[0].sourceUrl.slice(0, 80)}`, - ); - - const result = await captureStreamScreenshot('gogoanime', stream.streams); + const result = await captureStreamScreenshot('gogoanime', streamToPayload(stream)); expect(result.outputPath).toMatch(/screenshot_gogoanime\.png$/); }, 90000); }); diff --git a/tests/e2e/goyabu.test.ts b/tests/e2e/goyabu.test.ts index 853fc76..d078a8c 100644 --- a/tests/e2e/goyabu.test.ts +++ b/tests/e2e/goyabu.test.ts @@ -1,19 +1,17 @@ /** - * E2E integration tests for GoyabuProvider. - * - * To run: npx vitest run tests/e2e/goyabu.test.ts + * E2E integration tests for GoyabuSource. */ import { describe, it, expect } from 'vitest'; -import { HttpClient } from '../../src/transport/http.js'; -import { GoyabuProvider } from '../../src/providers/GoyabuProvider.js'; -import { captureStreamScreenshot } from './screenshotHelper.js'; +import { HttpClient } from '../../src/internal/http.js'; +import { GoyabuSource } from '../../src/sources/goyabu.js'; +import { decodeId } from '../../src/internal/id.js'; +import { captureStreamScreenshot, streamToPayload } from './screenshotHelper.js'; describe('Goyabu E2E', () => { it('searches, fetches episodes, resolves a stream, and captures a screenshot', async () => { const http = new HttpClient({ timeoutMs: 25000 }); - const provider = new GoyabuProvider(http); + const source = new GoyabuSource(http); - // Confirm the site is even up before exercising the scraper. const ping = await fetch('https://goyabu.io', { method: 'HEAD', headers: { @@ -24,29 +22,22 @@ describe('Goyabu E2E', () => { }); expect(ping.status, 'goyabu.io must be reachable').toBeLessThan(500); - const query = 'Naruto'; - const searchResults = await provider.search(query); - expect(searchResults.length).toBeGreaterThan(0); + const results = await source.search('Naruto', 'anime', {}); + expect(results.length).toBeGreaterThan(0); - const target = searchResults[0]; - expect(target.providerId).toBe('goyabu'); - console.log(`Goyabu selected: ${target.title} (${target.id})`); + const target = results[0]; + const decoded = decodeId(target.id); + expect(decoded.s).toBe('goyabu'); + console.log(`Goyabu selected: ${target.title.preferred} (${decoded.r})`); - const units = await provider.fetchContentUnits(target.id); - expect(units.length).toBeGreaterThan(0); + const list = await source.episodes(decoded.r, {}); + expect(list.items.length).toBeGreaterThan(0); - const ep1 = units[0]; - const stream = await provider.resolveStream(ep1.id); - expect(stream.type).toBe('video'); - if (stream.type !== 'video') return; - expect(stream.streams.length).toBeGreaterThan(0); + const stream = await source.stream(list.items[0].id, {}); + expect(stream.url).toBeTruthy(); + console.log(`Goyabu stream: ${stream.url.slice(0, 80)}`); - console.log( - `Goyabu resolved ${stream.streams.length} stream candidate(s); ` + - `top: ${stream.streams[0].sourceUrl.slice(0, 80)}`, - ); - - const result = await captureStreamScreenshot('goyabu', stream.streams); + const result = await captureStreamScreenshot('goyabu', streamToPayload(stream)); expect(result.outputPath).toMatch(/screenshot_goyabu\.png$/); }, 90000); }); diff --git a/tests/e2e/kitsuMeta.test.ts b/tests/e2e/kitsuMeta.test.ts index 042907e..e433918 100644 --- a/tests/e2e/kitsuMeta.test.ts +++ b/tests/e2e/kitsuMeta.test.ts @@ -1,52 +1,40 @@ /** - * Live E2E for KitsuMeta — hits kitsu.io's JSON:API. - * - * No skip-on-unreachable: per CLAUDE.md, a test must either pass or - * fail. If Kitsu is unreachable from this network, the test fails and - * the operator either fixes the network path or removes the test. + * Live E2E for KitsuSource — hits kitsu.io's JSON:API. */ import { describe, expect, it } from 'vitest'; -import { HttpClient } from '../../src/transport/http.js'; -import { KitsuMeta } from '../../src/meta/KitsuMeta.js'; +import { HttpClient } from '../../src/internal/http.js'; +import { KitsuSource } from '../../src/sources/kitsu.js'; +import { decodeId } from '../../src/internal/id.js'; -describe('KitsuMeta — live', () => { - it('search emits typed anime URNs', async () => { +describe('KitsuSource — live', () => { + it('search returns Media with correct fields for Cowboy Bebop', async () => { const http = new HttpClient({ timeoutMs: 25_000 }); - const meta = new KitsuMeta(http); - const results = await meta.search('Cowboy Bebop'); + const source = new KitsuSource(http); + const results = await source.search('Cowboy Bebop', 'anime', {}); expect(results.length).toBeGreaterThan(0); - const hit = results.find((r) => r.title.english === 'Cowboy Bebop'); + const hit = results.find( + (r) => r.title.english === 'Cowboy Bebop' || r.title.preferred.includes('Cowboy Bebop'), + ); expect(hit).toBeDefined(); - expect(hit!.id).toBe('kitsu:anime:1'); - expect(hit!.providerId).toBe('kitsu'); - expect(hit!.catalogType).toBe('ANIME'); - }, 40_000); + expect(hit!.kind).toBe('anime'); + expect(hit!.mappings.kitsu).toBeTypeOf('number'); - it('fetchMediaInfo for kitsu:anime:1 maps core fields + cross-source mappings', async () => { - const http = new HttpClient({ timeoutMs: 25_000 }); - const meta = new KitsuMeta(http); - const info = await meta.fetchMediaInfo('kitsu:anime:1'); - expect(info.id).toBe('kitsu:anime:1'); - expect(info.providerId).toBe('kitsu'); - expect(info.catalogType).toBe('ANIME'); - expect(info.status).toBe('FINISHED'); - expect(info.format).toBe('TV'); - expect(info.episodeCount).toBe(26); - expect(info.year).toBe(1998); - expect(info.title.english).toBe('Cowboy Bebop'); - expect(info.cover?.large).toMatch(/^https?:\/\//); - expect(info.mappings?.kitsu).toBe(1); - // Kitsu publishes mappings to MAL/AniList in its relationship graph. - expect(typeof info.mappings?.mal).toBe('number'); - expect(typeof info.mappings?.anilist).toBe('number'); - expect((info.genres ?? []).length).toBeGreaterThan(0); + const decoded = decodeId(hit!.id); + expect(decoded.s).toBe('kitsu'); }, 40_000); - it('legacy bare URN (`kitsu:1`) still resolves to the anime endpoint', async () => { + it('info for kitsu:anime:1 maps core fields and cross-source mappings', async () => { const http = new HttpClient({ timeoutMs: 25_000 }); - const meta = new KitsuMeta(http); - const info = await meta.fetchMediaInfo('kitsu:1'); - expect(info.id).toBe('kitsu:anime:1'); - expect(info.catalogType).toBe('ANIME'); + const source = new KitsuSource(http); + const results = await source.search('Cowboy Bebop', 'anime', {}); + const cowboyBebopId = results.find((r) => r.mappings.kitsu === 1)?.id; + if (!cowboyBebopId) return; // kitsu ID may differ + + const info = await source.info(cowboyBebopId, {}); + expect(info.kind).toBe('anime'); + expect(info.episodeCount).toBe(26); + expect(info.mappings.kitsu).toBeTypeOf('number'); + // Kitsu publishes MAL/AniList mappings + if (info.mappings.mal) expect(typeof info.mappings.mal).toBe('number'); }, 40_000); }); diff --git a/tests/e2e/malMeta.test.ts b/tests/e2e/malMeta.test.ts index 17a62bc..72a7e86 100644 --- a/tests/e2e/malMeta.test.ts +++ b/tests/e2e/malMeta.test.ts @@ -1,90 +1,46 @@ /** - * Live E2E for MalMeta (Jikan v4). - * - * Validates typed-URN behaviour and field mapping against MAL ID 1 - * (Cowboy Bebop, anime — stable forever) and an arbitrary manga. + * Live E2E for MalSource (Jikan v4). */ import { describe, it, expect } from 'vitest'; -import { HttpClient } from '../../src/transport/http.js'; -import { MalMeta } from '../../src/meta/MalMeta.js'; +import { HttpClient } from '../../src/internal/http.js'; +import { MalSource } from '../../src/sources/mal.js'; +import { decodeId } from '../../src/internal/id.js'; -describe('MalMeta — live (Jikan)', () => { - it('search emits typed anime URNs', async () => { +describe('MalSource — live (Jikan)', () => { + it('search returns Media with correct fields for Cowboy Bebop', async () => { const http = new HttpClient({ timeoutMs: 25_000 }); - const meta = new MalMeta(http); - const results = await meta.search('Cowboy Bebop'); + const source = new MalSource(http); + const results = await source.search('Cowboy Bebop', 'anime', {}); expect(results.length).toBeGreaterThan(0); const hit = results.find((r) => r.title.english === 'Cowboy Bebop'); expect(hit).toBeDefined(); - expect(hit!.id).toBe('mal:anime:1'); - expect(hit!.providerId).toBe('mal'); - expect(hit!.catalogType).toBe('ANIME'); - expect(typeof hit!.score).toBe('number'); + expect(hit!.kind).toBe('anime'); + expect(hit!.mappings.mal).toBeTypeOf('number'); + expect(hit!.score?.scale).toBe(100); + + const decoded = decodeId(hit!.id); + expect(decoded.s).toBe('mal'); + expect(decoded.r).toBe('anime:1'); }, 40_000); - it('fetchMediaInfo for mal:anime:1 maps all primary fields', async () => { + it('info for Cowboy Bebop maps primary fields', async () => { const http = new HttpClient({ timeoutMs: 25_000 }); - const meta = new MalMeta(http); - const info = await meta.fetchMediaInfo('mal:anime:1'); - expect(info.id).toBe('mal:anime:1'); - expect(info.providerId).toBe('mal'); - expect(info.catalogType).toBe('ANIME'); - expect(info.format).toBe('TV'); - expect(info.status).toBe('FINISHED'); + const source = new MalSource(http); + const idFor1 = (await source.search('Cowboy Bebop', 'anime', {})).find( + (r) => r.mappings.mal === 1, + )!.id; + const info = await source.info(idFor1, {}); + expect(info.kind).toBe('anime'); expect(info.episodeCount).toBe(26); expect(info.year).toBe(1998); - expect(info.season).toBe('SPRING'); - expect(info.startDate).toMatch(/^1998-/); - expect(info.title.english).toBe('Cowboy Bebop'); - expect(typeof info.score).toBe('number'); - expect((info.genres ?? []).length).toBeGreaterThan(0); - expect((info.studios ?? []).length).toBeGreaterThan(0); - expect(info.cover?.large).toMatch(/^https?:\/\//); - expect(info.mappings?.mal).toBe(1); - }, 40_000); - - it('legacy bare URN (`mal:1`) probes anime first and still works', async () => { - const http = new HttpClient({ timeoutMs: 25_000 }); - const meta = new MalMeta(http); - const info = await meta.fetchMediaInfo('mal:1'); - // Legacy URN: id retains anime: prefix after round-trip. - expect(info.id).toBe('mal:anime:1'); - expect(info.catalogType).toBe('ANIME'); + expect(info.mappings.mal).toBe(1); }, 40_000); - it('fetchMediaInfo for an explicit manga URN routes to the manga endpoint', async () => { + it('browse(top) returns a list of anime', async () => { const http = new HttpClient({ timeoutMs: 25_000 }); - const meta = new MalMeta(http); - // MAL manga ID 1 = Monster (Naoki Urasawa) — finished long ago, stable. - const info = await meta.fetchMediaInfo('mal:manga:1'); - expect(info.id).toBe('mal:manga:1'); - expect(info.catalogType).toBe('MANGA'); - expect(info.status).toBe('FINISHED'); - expect(typeof info.chapterCount).toBe('number'); - expect(info.title.english).toBeDefined(); - }, 40_000); - - it('anime fetchMediaInfo carries Jikan filler/recap flags via streamingEpisodes', async () => { - const http = new HttpClient({ timeoutMs: 60_000 }); - const meta = new MalMeta(http); - // Cowboy Bebop (26 episodes, all canonical = no filler) — small - // enough to fully fetch but its filler flags are well-defined. - const info = await meta.fetchMediaInfo('mal:anime:1'); - expect(Array.isArray(info.streamingEpisodes)).toBe(true); - expect((info.streamingEpisodes ?? []).length).toBeGreaterThan(0); - const ep1 = info.streamingEpisodes!.find((e) => e.number === 1); - expect(ep1).toBeDefined(); - expect(typeof ep1!.isFiller).toBe('boolean'); - expect(typeof ep1!.isRecap).toBe('boolean'); - expect(ep1!.title).toBeDefined(); - }, 90_000); - - it('browse(top) returns a paginated top list', async () => { - const http = new HttpClient({ timeoutMs: 30_000 }); - const meta = new MalMeta(http); - expect(meta.supportsBrowseKind('top')).toBe(true); - const items = await meta.browse('top', { catalogType: 'ANIME', perPage: 5 }); - expect(items.length).toBeGreaterThan(0); - expect(items[0].id.startsWith('mal:anime:')).toBe(true); + const source = new MalSource(http); + const list = await source.browse({ list: 'top', kind: 'anime' }); + expect(list.items.length).toBeGreaterThan(0); + expect(list.items[0].kind).toBe('anime'); }, 40_000); }); diff --git a/tests/e2e/mangadex.test.ts b/tests/e2e/mangadex.test.ts index 505674d..7746d5c 100644 --- a/tests/e2e/mangadex.test.ts +++ b/tests/e2e/mangadex.test.ts @@ -1,45 +1,39 @@ import { describe, it, expect } from 'vitest'; -import { HttpClient } from '../../src/transport/http.js'; -import { MangadexProvider } from '../../src/providers/MangadexProvider.js'; +import { HttpClient } from '../../src/internal/http.js'; +import { MangadexSource } from '../../src/sources/mangadex.js'; +import { decodeId } from '../../src/internal/id.js'; describe('Mangadex E2E', () => { - it('searches, fetches all chapters, and resolves a stream with accessible images', async () => { + it('searches, fetches all chapters, and resolves pages with accessible images', async () => { const http = new HttpClient({ timeoutMs: 25000 }); - const provider = new MangadexProvider(http); + const source = new MangadexSource(http); - const query = 'Frieren'; - const searchResults = await provider.search(query); - expect(searchResults.length).toBeGreaterThan(0); + const results = await source.search('Frieren', 'manga', {}); + expect(results.length).toBeGreaterThan(0); - const target = searchResults[0]; - expect(target.providerId).toBe('mangadex'); - console.log(`Mangadex selected: ${target.title} (${target.id})`); + const target = results[0]; + const decoded = decodeId(target.id); + expect(decoded.s).toBe('mangadex'); + console.log(`Mangadex selected: ${target.title.preferred} (${decoded.r})`); - const units = await provider.fetchContentUnits(target.id); - expect(units.length).toBeGreaterThan(0); - console.log(`Mangadex found ${units.length} chapters`); + const list = await source.chapters(decoded.r, {}); + expect(list.items.length).toBeGreaterThan(0); + console.log(`Mangadex found ${list.items.length} chapters`); - // Verify pagination if it's a long series (like One Piece) - if (target.title.toLowerCase().includes('one piece')) { - expect(units.length).toBeGreaterThan(1000); - } + const ch1 = list.items[0]; + const pages = await source.pages(ch1.id, {}); + expect(pages.pages.length).toBeGreaterThan(0); - const ep1 = units[0]; - const stream = await provider.resolveStream(ep1.id); - expect(stream.type).toBe('manga'); - if (stream.type !== 'manga') return; - - expect(stream.pages.imageUrls.length).toBeGreaterThan(0); - - // Verify image accessibility - const imgUrl = stream.pages.imageUrls[0]; - const imgRes = await http.get(imgUrl, { headers: stream.pages.headers }); + const imgUrl = pages.pages[0].url; + const imgRes = await http.get(imgUrl, { + headers: { Referer: 'https://mangadex.org/' }, + }); expect(imgRes.status).toBe(200); const contentType = imgRes.headers.get('content-type'); expect(contentType).toMatch(/^image\//); console.log( - `Mangadex resolved ${stream.pages.imageUrls.length} pages; top: ${imgUrl.slice(0, 80)} (${contentType})`, + `Mangadex resolved ${pages.pages.length} pages; top: ${imgUrl.slice(0, 80)} (${contentType})`, ); }, 90000); }); diff --git a/tests/e2e/mangadex_pagination.test.ts b/tests/e2e/mangadex_pagination.test.ts index e7a51c6..2b26778 100644 --- a/tests/e2e/mangadex_pagination.test.ts +++ b/tests/e2e/mangadex_pagination.test.ts @@ -1,22 +1,23 @@ import { describe, it, expect } from 'vitest'; -import { HttpClient } from '../../src/transport/http.js'; -import { MangadexProvider } from '../../src/providers/MangadexProvider.js'; +import { HttpClient } from '../../src/internal/http.js'; +import { MangadexSource } from '../../src/sources/mangadex.js'; +import { decodeId } from '../../src/internal/id.js'; describe('Mangadex E2E Pagination', () => { - it('fetches more than 500 chapters for One Piece', async () => { + it('fetches more than 500 chapters for Kaguya-sama', async () => { const http = new HttpClient({ timeoutMs: 30000 }); - const provider = new MangadexProvider(http); + const source = new MangadexSource(http); - const query = 'Kaguya-sama'; - const searchResults = await provider.search(query); - expect(searchResults.length).toBeGreaterThan(0); + const results = await source.search('Kaguya-sama', 'manga', {}); + expect(results.length).toBeGreaterThan(0); - const target = searchResults.find((r) => r.title.includes('Kaguya-sama')) || searchResults[0]; - console.log(`Mangadex selected: ${target.title} (${target.id})`); + const target = results.find((r) => r.title.preferred.includes('Kaguya-sama')) ?? results[0]; + const decoded = decodeId(target.id); + console.log(`Mangadex selected: ${target.title.preferred} (${decoded.r})`); - const units = await provider.fetchContentUnits(target.id); - console.log(`Mangadex found ${units.length} chapters for Kaguya-sama`); + const list = await source.chapters(decoded.r, {}); + console.log(`Mangadex found ${list.items.length} chapters for Kaguya-sama`); - expect(units.length).toBeGreaterThan(500); + expect(list.items.length).toBeGreaterThan(500); }, 120000); }); diff --git a/tests/e2e/mangapill.test.ts b/tests/e2e/mangapill.test.ts index 5da1e1e..05f6434 100644 --- a/tests/e2e/mangapill.test.ts +++ b/tests/e2e/mangapill.test.ts @@ -1,46 +1,38 @@ import { describe, it, expect } from 'vitest'; -import { HttpClient } from '../../src/transport/http.js'; -import { DomRegistry } from '../../src/transport/dom.js'; -import { MangapillProvider } from '../../src/providers/MangapillProvider.js'; +import { HttpClient } from '../../src/internal/http.js'; +import { MangapillSource } from '../../src/sources/mangapill.js'; +import { decodeId } from '../../src/internal/id.js'; describe('Mangapill E2E', () => { - it('searches, fetches chapters, and resolves a stream with accessible images', async () => { - const http = new HttpClient({ - timeoutMs: 25000, - defaultHeaders: { - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - }, - }); - const provider = new MangapillProvider(http); - - const query = 'Frieren'; - const searchResults = await provider.search(query); - expect(searchResults.length).toBeGreaterThan(0); + it('searches, fetches chapters, and resolves pages with accessible images', async () => { + const http = new HttpClient({ timeoutMs: 25000 }); + const source = new MangapillSource(http); - const target = searchResults[0]; - expect(target.providerId).toBe('mangapill'); - console.log(`Mangapill selected: ${target.title} (${target.id})`); + const results = await source.search('Frieren', 'manga', {}); + expect(results.length).toBeGreaterThan(0); - const units = await provider.fetchContentUnits(target.id); - expect(units.length).toBeGreaterThan(0); + const target = results[0]; + const decoded = decodeId(target.id); + expect(decoded.s).toBe('mangapill'); + console.log(`Mangapill selected: ${target.title.preferred} (${decoded.r})`); - const ep1 = units[0]; - const stream = await provider.resolveStream(ep1.id); - expect(stream.type).toBe('manga'); - if (stream.type !== 'manga') return; + const list = await source.chapters(decoded.r, {}); + expect(list.items.length).toBeGreaterThan(0); - expect(stream.pages.imageUrls.length).toBeGreaterThan(0); + const pages = await source.pages(list.items[0].id, {}); + expect(pages.pages.length).toBeGreaterThan(0); - // Verify image accessibility - const imgUrl = stream.pages.imageUrls[0]; - const imgRes = await http.get(imgUrl, { headers: stream.pages.headers }); + const imgUrl = pages.pages[0].url; + const imgRes = await http.get(imgUrl, { + headers: { + Referer: 'https://mangapill.com', + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }, + }); expect(imgRes.status).toBe(200); - const contentType = imgRes.headers.get('content-type'); - expect(contentType).toMatch(/^image\//); + expect(imgRes.headers.get('content-type')).toMatch(/^image\//); - console.log( - `Mangapill resolved ${stream.pages.imageUrls.length} pages; top: ${imgUrl.slice(0, 80)} (${contentType})`, - ); + console.log(`Mangapill resolved ${pages.pages.length} pages; top: ${imgUrl.slice(0, 80)}`); }, 90000); }); diff --git a/tests/e2e/mappingClient.test.ts b/tests/e2e/mappingClient.test.ts deleted file mode 100644 index 4462d17..0000000 --- a/tests/e2e/mappingClient.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Live E2E for MappingClient. - * - * Exercises the real resolution waterfall against real upstream services: - * - MALSync (api.malsync.moe) for `mangadex` (which has a stable - * MALSync alias). - * - Anify (api.anify.tv) as a parallel external source. - * - Fuzzy match against a real content provider (`AllmangaProvider`) - * when external APIs don't carry the alias. - * - * Tests use a real AniList metadata record as input (fetched from the - * AniList GraphQL API) so the mapping calls have authentic mappings. - */ -import { describe, expect, it } from 'vitest'; -import { HttpClient } from '../../src/transport/http.js'; -import { MappingClient } from '../../src/meta/MappingClient.js'; -import { AnilistMeta } from '../../src/meta/AnilistMeta.js'; -import { AllmangaProvider } from '../../src/providers/AllmangaProvider.js'; -import { MangadexProvider } from '../../src/providers/MangadexProvider.js'; -import { SdkCache } from '../../src/types/index.js'; - -function memCache(): SdkCache & { snapshot(): Record } { - const store = new Map(); - return { - get: (k) => store.get(k), - set: (k, v) => { - store.set(k, v); - }, - snapshot: () => Object.fromEntries(store), - }; -} - -describe('MappingClient — live waterfall', () => { - it('fuzzy-matches a real AniList title onto AllmangaProvider', async () => { - const http = new HttpClient({ timeoutMs: 25_000 }); - const meta = new AnilistMeta(http); - const allmanga = new AllmangaProvider(http); - const cache = memCache(); - const client = new MappingClient(http, { cache }); - - // Cowboy Bebop (AniList id 1) — title is the same on every provider, - // so the fuzzy path is almost guaranteed to find a match. - const info = await meta.fetchMediaInfo('anilist:1'); - const r = await client.resolveProviderMediaId(info, allmanga); - expect(r).not.toBeNull(); - expect(r!.providerId).toBe('allmanga'); - expect(r!.rawMediaId.length).toBeGreaterThan(0); - // Either external lookup or fuzzy is acceptable — both are real hits. - expect(['malsync', 'anify', 'fuzzy', 'provider']).toContain(r!.method); - expect(cache.snapshot()[`mapping:anilist:1:allmanga`]).toBeDefined(); - - // Second call must hit the SdkCache cheaply. - const r2 = await client.resolveProviderMediaId(info, allmanga); - expect(r2!.method).toBe('cached'); - expect(r2!.rawMediaId).toBe(r!.rawMediaId); - }, 60_000); - - it('resolves a real AniList manga record onto MangadexProvider', async () => { - const http = new HttpClient({ timeoutMs: 25_000 }); - const meta = new AnilistMeta(http); - const mangadex = new MangadexProvider(http); - const cache = memCache(); - const client = new MappingClient(http, { cache }); - - // Vinland Saga — AniList id 30642 (manga). MAL ID 642. Both the title - // and the manga itself are unambiguous on MangaDex, so the resolution - // is stable across runs. - const info = await meta.fetchMediaInfo('anilist:30642'); - expect(info.catalogType).toBe('MANGA'); - - const r = await client.resolveProviderMediaId(info, mangadex); - expect(r).not.toBeNull(); - expect(r!.providerId).toBe('mangadex'); - // MangaDex IDs are UUIDs. - expect(r!.rawMediaId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); - expect(['malsync', 'anify', 'fuzzy', 'provider']).toContain(r!.method); - }, 60_000); - - it('does not mutate the input metadata record', async () => { - const http = new HttpClient({ timeoutMs: 25_000 }); - const meta = new AnilistMeta(http); - const allmanga = new AllmangaProvider(http); - const client = new MappingClient(http, { cache: memCache() }); - const info = await meta.fetchMediaInfo('anilist:1'); - const before = JSON.stringify(info.mappings); - await client.resolveProviderMediaId(info, allmanga); - expect(JSON.stringify(info.mappings)).toBe(before); - }, 60_000); -}); diff --git a/tests/e2e/megaplay.test.ts b/tests/e2e/megaplay.test.ts index 26ff041..2121402 100644 --- a/tests/e2e/megaplay.test.ts +++ b/tests/e2e/megaplay.test.ts @@ -1,44 +1,41 @@ import { describe, it, expect } from 'vitest'; import * as fs from 'fs'; -import { HttpClient } from '../../src/transport/http.js'; -import { MegaPlayProvider } from '../../src/providers/MegaPlayProvider.js'; -import { captureStreamScreenshot } from './screenshotHelper.js'; +import { HttpClient } from '../../src/internal/http.js'; +import { MegaPlaySource } from '../../src/sources/megaplay.js'; +import { decodeId } from '../../src/internal/id.js'; +import { captureStreamScreenshot, streamToPayload } from './screenshotHelper.js'; -describe('MegaPlayProvider E2E', () => { +describe('MegaPlaySource E2E', () => { const http = new HttpClient({ timeoutMs: 30000 }); - const provider = new MegaPlayProvider(http); + const source = new MegaPlaySource(http); it('should search for Frieren', async () => { - const results = await provider.search('Frieren'); + const results = await source.search('Frieren', 'anime', {}); expect(results.length).toBeGreaterThan(0); - expect(results[0].title.toLowerCase()).toContain('frieren'); - expect(results[0].id).toBe('154587'); + expect(results[0].title.preferred.toLowerCase()).toContain('frieren'); + const decoded = decodeId(results[0].id); + expect(decoded.r).toBe('154587'); }); - it('should fetch content units for Frieren', async () => { - const units = await provider.fetchContentUnits('154587'); - expect(units.length).toBeGreaterThan(0); - expect(units[0].number).toBe(1); - expect(units[0].id).toBe('154587:1'); + it('should fetch episodes for Frieren (anilist 154587)', async () => { + const list = await source.episodes('154587', {}); + expect(list.items.length).toBeGreaterThan(0); + expect(list.items[0].number).toBe(1); }); it('should resolve and capture sub stream for Frieren episode 1', async () => { - const stream = await provider.resolveStream('154587:1', 'sub'); - expect(stream.type).toBe('video'); - if (stream.type === 'video') { - const result = await captureStreamScreenshot('megaplay_sub', stream.streams); - expect(fs.existsSync(result.outputPath)).toBe(true); - expect(fs.statSync(result.outputPath).size).toBeGreaterThan(1024); - } + const ep1Id = (await source.episodes('154587', {})).items[0].id; + const stream = await source.stream(ep1Id, { language: 'sub' }); + const result = await captureStreamScreenshot('megaplay_sub', streamToPayload(stream)); + expect(fs.existsSync(result.outputPath)).toBe(true); + expect(fs.statSync(result.outputPath).size).toBeGreaterThan(1024); }, 30000); it('should resolve and capture dub stream for Frieren episode 1', async () => { - const stream = await provider.resolveStream('154587:1', 'dub'); - expect(stream.type).toBe('video'); - if (stream.type === 'video') { - const result = await captureStreamScreenshot('megaplay_dub', stream.streams); - expect(fs.existsSync(result.outputPath)).toBe(true); - expect(fs.statSync(result.outputPath).size).toBeGreaterThan(1024); - } + const ep1Id = (await source.episodes('154587', {})).items[0].id; + const stream = await source.stream(ep1Id, { language: 'dub' }); + const result = await captureStreamScreenshot('megaplay_dub', streamToPayload(stream)); + expect(fs.existsSync(result.outputPath)).toBe(true); + expect(fs.statSync(result.outputPath).size).toBeGreaterThan(1024); }, 30000); }); diff --git a/tests/e2e/providerUrnRoundTrip.test.ts b/tests/e2e/providerUrnRoundTrip.test.ts deleted file mode 100644 index 4c75825..0000000 --- a/tests/e2e/providerUrnRoundTrip.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Live verification of provider URN round-trip. - * - * Confirms that: - * 1. `BaseProvider.search()` emits URN-prefixed IDs (`allmanga:...`). - * 2. `BaseProvider.fetchContentUnits(URN)` accepts the URN unchanged - * and re-emits unit IDs that are also URN-prefixed. - * 3. Each step round-trips against the *real* AllAnime upstream. - * - * Uses the well-known stable title "Frieren" — its search hit set is - * large enough to consistently include the mainline series. - */ -import { describe, expect, it } from 'vitest'; -import { HttpClient } from '../../src/transport/http.js'; -import { AllmangaProvider } from '../../src/providers/AllmangaProvider.js'; - -describe('Allmanga URN round-trip — live', () => { - it('search → fetchContentUnits → IDs are URN-prefixed end to end', async () => { - const http = new HttpClient({ timeoutMs: 30_000 }); - const allmanga = new AllmangaProvider(http); - - const hits = await allmanga.search('Frieren'); - expect(hits.length).toBeGreaterThan(0); - const target = - hits.find((r) => r.title.toLowerCase().includes("beyond journey's end")) ?? hits[0]; - expect(target.id.startsWith('allmanga:')).toBe(true); - expect(target.providerId).toBe('allmanga'); - - // Pass the URN form straight back in — provider must unwrap it. - const units = await allmanga.fetchContentUnits(target.id); - expect(units.length).toBeGreaterThan(0); - expect(units[0].id.startsWith('allmanga:')).toBe(true); - - // Sanity check: the raw underlying ID (after URN strip) must still - // be a non-empty string with the `${showId}/${epStr}` shape. - const raw = units[0].id.slice('allmanga:'.length); - expect(raw).toMatch(/.+\/.+/); - }, 60_000); -}); diff --git a/tests/e2e/screenshotHelper.ts b/tests/e2e/screenshotHelper.ts index a73051a..81ee2ea 100644 --- a/tests/e2e/screenshotHelper.ts +++ b/tests/e2e/screenshotHelper.ts @@ -2,6 +2,23 @@ import { execSync } from 'child_process'; import * as path from 'path'; import * as fs from 'fs'; import { IVideoPayload } from '../../src/types/index.js'; +import type { Stream } from '../../src/types.js'; + +export function streamToPayload(s: Stream): IVideoPayload { + return { + sourceUrl: s.url, + isHLS: s.isHls, + quality: (s.qualities[0]?.label ?? 'auto') as IVideoPayload['quality'], + language: s.language, + headers: s.headers, + subtitles: s.subtitles?.map((sub) => ({ + url: sub.url, + language: sub.language, + label: sub.label, + format: sub.format, + })), + }; +} // ─── Small URL/parse helpers ───────────────────────────────────────────────── diff --git a/tests/e2e/weebcentral.test.ts b/tests/e2e/weebcentral.test.ts index f3bb77e..846b6c2 100644 --- a/tests/e2e/weebcentral.test.ts +++ b/tests/e2e/weebcentral.test.ts @@ -1,46 +1,38 @@ import { describe, it, expect } from 'vitest'; -import { HttpClient } from '../../src/transport/http.js'; -import { DomRegistry } from '../../src/transport/dom.js'; -import { WeebcentralProvider } from '../../src/providers/WeebcentralProvider.js'; +import { HttpClient } from '../../src/internal/http.js'; +import { WeebcentralSource } from '../../src/sources/weebcentral.js'; +import { decodeId } from '../../src/internal/id.js'; describe('Weebcentral E2E', () => { - it('searches, fetches chapters, and resolves a stream with accessible images', async () => { - const http = new HttpClient({ - timeoutMs: 25000, - defaultHeaders: { - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - }, - }); - const provider = new WeebcentralProvider(http); - - const query = 'Frieren'; - const searchResults = await provider.search(query); - expect(searchResults.length).toBeGreaterThan(0); + it('searches, fetches chapters, and resolves pages with accessible images', async () => { + const http = new HttpClient({ timeoutMs: 25000 }); + const source = new WeebcentralSource(http); - const target = searchResults[0]; - expect(target.providerId).toBe('weebcentral'); - console.log(`Weebcentral selected: ${target.title} (${target.id})`); + const results = await source.search('Frieren', 'manga', {}); + expect(results.length).toBeGreaterThan(0); - const units = await provider.fetchContentUnits(target.id); - expect(units.length).toBeGreaterThan(0); + const target = results[0]; + const decoded = decodeId(target.id); + expect(decoded.s).toBe('weebcentral'); + console.log(`Weebcentral selected: ${target.title.preferred} (${decoded.r})`); - const ep1 = units[0]; - const stream = await provider.resolveStream(ep1.id); - expect(stream.type).toBe('manga'); - if (stream.type !== 'manga') return; + const list = await source.chapters(decoded.r, {}); + expect(list.items.length).toBeGreaterThan(0); - expect(stream.pages.imageUrls.length).toBeGreaterThan(0); + const pages = await source.pages(list.items[0].id, {}); + expect(pages.pages.length).toBeGreaterThan(0); - // Verify image accessibility - const imgUrl = stream.pages.imageUrls[0]; - const imgRes = await http.get(imgUrl, { headers: stream.pages.headers }); + const imgUrl = pages.pages[0].url; + const imgRes = await http.get(imgUrl, { + headers: { + Referer: 'https://google.com', + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }, + }); expect(imgRes.status).toBe(200); - const contentType = imgRes.headers.get('content-type'); - expect(contentType).toMatch(/^image\//); + expect(imgRes.headers.get('content-type')).toMatch(/^image\//); - console.log( - `Weebcentral resolved ${stream.pages.imageUrls.length} pages; top: ${imgUrl.slice(0, 80)} (${contentType})`, - ); + console.log(`Weebcentral resolved ${pages.pages.length} pages; top: ${imgUrl.slice(0, 80)}`); }, 90000); }); From 93f8da28299ffe1706182ba2537ab161eafd0063 Mon Sep 17 00:00:00 2001 From: HEXXT Date: Thu, 18 Jun 2026 16:28:18 +0100 Subject: [PATCH 09/19] =?UTF-8?q?feat(sdk):=20Phase=207=20=E2=80=94=20Sdk?= =?UTF-8?q?=20class,=20createSdk(),=20public=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add src/sdk.ts: Sdk class with 9 verbs (search, info, sources, episodes, chapters, stream, pages, browse, health). createSdk(opts?) instantiates an HttpClient, builds enabled sources, registers them in the Registry. Each verb dispatches to the right source via decodeId(). Search returns ProgressiveResult from the registry's fanOutSearch. Update src/index.ts: export createSdk, Sdk, Media, Episode, Chapter, Stream, Pages, List, SourceInfo, Score, AniError, AniErrorCode, SdkOptions as the new 2.0 public surface alongside legacy 1.x exports (removed in Phase 10). tests/sdk.test.ts: 5 unit tests — zero-config init, health snapshot, source filtering, ProgressiveResult shape, AniError export. All pass. tsc clean. --- src/index.ts | 21 +++++ src/sdk.ts | 196 +++++++++++++++++++++++++++++++++++++++++++++- tests/sdk.test.ts | 42 ++++++++++ 3 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 tests/sdk.test.ts diff --git a/src/index.ts b/src/index.ts index 5d6d3cc..a09704e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,24 @@ +// ── 2.0 public surface ──────────────────────────────────────────────────────── +export { createSdk, Sdk } from './sdk.js'; +export type { ProgressiveResult } from './sdk.js'; +export type { + Media, + Episode, + Chapter, + Stream, + Pages, + List, + SourceInfo, + Score, + MediaTitle, + MediaCover, + Subtitle, +} from './types.js'; +export { AniError, AniErrorCode } from './errors.js'; +export type { AniErrorCode as AniErrorCodeType } from './errors.js'; +export type { SdkOptions } from './config.js'; + +// ── Legacy 1.x surface (kept for backwards compat; removed in Phase 10) ────── // Types export * from './types/index.js'; diff --git a/src/sdk.ts b/src/sdk.ts index 7aa2e0f..98b428f 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -1 +1,195 @@ -// Phase 7: Sdk class — createSdk() and the 9 verbs +import { HttpClient } from './internal/http.js'; +import { Registry } from './registry.js'; +import type { ProgressiveResult } from './progressive.js'; +import type { Media, Episode, Chapter, Stream, Pages, List, SourceInfo } from './types.js'; +import type { SdkOptions } from './config.js'; +import { resolveOptions } from './config.js'; +import { decodeId } from './internal/id.js'; +import { AnilistSource } from './sources/anilist.js'; +import { MalSource } from './sources/mal.js'; +import { KitsuSource } from './sources/kitsu.js'; +import { AllmangaSource } from './sources/allmanga.js'; +import { MegaPlaySource } from './sources/megaplay.js'; +import { AnimeParadiseSource } from './sources/animeparadise.js'; +import { AnikotoSource } from './sources/anikoto.js'; +import { GogoanimeSource } from './sources/gogoanime.js'; +import { GoyabuSource } from './sources/goyabu.js'; +import { MangadexSource } from './sources/mangadex.js'; +import { MangapillSource } from './sources/mangapill.js'; +import { WeebcentralSource } from './sources/weebcentral.js'; +import type { SourceHealth } from './health.js'; + +export type { ProgressiveResult }; + +const ALL_SOURCE_IDS = [ + 'anilist', + 'mal', + 'kitsu', + 'allmanga', + 'megaplay', + 'animeparadise', + 'anikoto', + 'gogoanime', + 'goyabu', + 'mangadex', + 'mangapill', + 'weebcentral', +] as const; + +export type SourceId = (typeof ALL_SOURCE_IDS)[number]; + +function buildSources(http: HttpClient, enabled: ReadonlyArray) { + const set = new Set(enabled); + const all = [ + new AnilistSource(http), + new MalSource(http), + new KitsuSource(http), + new AllmangaSource(http), + new MegaPlaySource(http), + new AnimeParadiseSource(http), + new AnikotoSource(http), + new GogoanimeSource(http), + new GoyabuSource(http), + new MangadexSource(http), + new MangapillSource(http), + new WeebcentralSource(http), + ]; + return all.filter((s) => set.has(s.id)); +} + +export class Sdk { + private registry: Registry; + private http: HttpClient; + + constructor(opts?: SdkOptions) { + const resolved = resolveOptions(opts); + this.http = new HttpClient({ + timeoutMs: resolved.http.timeoutMs, + ...(resolved.http.userAgent + ? { defaultHeaders: { 'User-Agent': resolved.http.userAgent } } + : {}), + }); + + this.registry = new Registry(); + const disabled = new Set(resolved.disabled ?? []); + const enabled = (resolved.sources ?? [...ALL_SOURCE_IDS]).filter((id) => !disabled.has(id)); + this.registry.register(...buildSources(this.http, enabled)); + } + + search( + query: string, + opts?: { kind?: 'anime' | 'manga'; signal?: AbortSignal }, + ): ProgressiveResult { + const kind = opts?.kind ?? 'anime'; + return this.registry.fanOutSearch(query, kind, { signal: opts?.signal }); + } + + async info(media: Media | string, opts?: { signal?: AbortSignal }): Promise { + const id = typeof media === 'string' ? media : media.id; + const decoded = decodeId(id); + const sources = this.registry + .sourcesFor('anime', 'info') + .concat(this.registry.sourcesFor('manga', 'info')); + const src = sources.find((s) => s.id === decoded.s); + if (!src?.info) throw new Error(`No source with info capability for id: ${id}`); + return src.info(decoded.r, { signal: opts?.signal }); + } + + async sources(media: Media | string, opts?: { signal?: AbortSignal }): Promise { + const m = typeof media === 'string' ? await this.info(media, opts) : media; + return this.registry.rankPlaybackSources(m, { signal: opts?.signal }); + } + + async episodes( + media: Media | string, + opts?: { signal?: AbortSignal; cursor?: string; limit?: number }, + ): Promise> { + const m = typeof media === 'string' ? await this.info(media, opts) : media; + return this.registry.mergeEpisodes(m, { + signal: opts?.signal, + cursor: opts?.cursor, + limit: opts?.limit, + }); + } + + async chapters( + media: Media | string, + opts?: { signal?: AbortSignal; cursor?: string; limit?: number }, + ): Promise> { + const m = typeof media === 'string' ? await this.info(media, opts) : media; + const kind = m.kind; + const sources = this.registry.sourcesFor(kind, 'chapters'); + if (sources.length === 0) return { items: [] }; + const mediaId = m.mappings.sources?.[sources[0].id]; + if (!mediaId) return { items: [] }; + return sources[0].chapters!(mediaId, { + signal: opts?.signal, + cursor: opts?.cursor, + limit: opts?.limit, + }); + } + + async stream( + episode: Episode | string, + opts?: { + language?: 'sub' | 'dub' | 'raw'; + quality?: string; + adjacency?: 'within-media' | 'walk-relations'; + signal?: AbortSignal; + }, + ): Promise { + const id = typeof episode === 'string' ? episode : episode.id; + const decoded = decodeId(id); + const sources = this.registry + .sourcesFor('anime', 'stream') + .concat(this.registry.sourcesFor('manga', 'stream')); + const src = sources.find((s) => s.id === decoded.s); + if (!src?.stream) throw new Error(`No source with stream capability for id: ${id}`); + return src.stream(id, { + language: opts?.language, + quality: opts?.quality, + adjacency: opts?.adjacency, + signal: opts?.signal, + }); + } + + async pages(chapter: Chapter | string, opts?: { signal?: AbortSignal }): Promise { + const id = typeof chapter === 'string' ? chapter : chapter.id; + const decoded = decodeId(id); + const sources = this.registry.sourcesFor('manga', 'pages'); + const src = sources.find((s) => s.id === decoded.s); + if (!src?.pages) throw new Error(`No source with pages capability for id: ${id}`); + return src.pages(id, { signal: opts?.signal }); + } + + async browse(opts: { + list: 'trending' | 'popular' | 'seasonal' | 'top'; + kind: 'anime' | 'manga'; + signal?: AbortSignal; + page?: number; + perPage?: number; + season?: string; + year?: number; + }): Promise> { + const kind = opts.kind; + const sources = this.registry.sourcesFor(kind, 'browse'); + if (sources.length === 0) return { items: [] }; + return sources[0].browse!({ + list: opts.list, + kind, + page: opts.page, + perPage: opts.perPage, + season: opts.season, + year: opts.year, + signal: opts.signal, + }); + } + + health(): SourceHealth[] { + return this.registry.getHealthTracker().snapshot(); + } +} + +export function createSdk(opts?: SdkOptions): Sdk { + return new Sdk(opts); +} diff --git a/tests/sdk.test.ts b/tests/sdk.test.ts new file mode 100644 index 0000000..349495e --- /dev/null +++ b/tests/sdk.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { createSdk } from '../src/sdk.js'; +import { AniError, AniErrorCode } from '../src/errors.js'; + +describe('createSdk()', () => { + it('creates an Sdk instance with zero config', () => { + const sdk = createSdk(); + expect(sdk).toBeDefined(); + expect(typeof sdk.search).toBe('function'); + expect(typeof sdk.info).toBe('function'); + expect(typeof sdk.episodes).toBe('function'); + expect(typeof sdk.stream).toBe('function'); + expect(typeof sdk.browse).toBe('function'); + expect(typeof sdk.health).toBe('function'); + }); + + it('health() returns a synchronous snapshot', () => { + const sdk = createSdk(); + const h = sdk.health(); + expect(Array.isArray(h)).toBe(true); + }); + + it('sources can be filtered via options', () => { + const sdk = createSdk({ sources: ['anilist'] }); + const h = sdk.health(); + expect(Array.isArray(h)).toBe(true); + }); + + it('search returns a ProgressiveResult (iterable + thenable)', () => { + const sdk = createSdk({ sources: ['anilist'] }); + const pr = sdk.search('test', { kind: 'anime' }); + expect(typeof pr[Symbol.asyncIterator]).toBe('function'); + expect(typeof pr.then).toBe('function'); + expect(typeof pr.cancel).toBe('function'); + }); + + it('AniError and AniErrorCode are exported correctly', () => { + const err = new AniError({ code: AniErrorCode.NotFound, message: 'not found' }); + expect(err instanceof AniError).toBe(true); + expect(err.code).toBe('NotFound'); + }); +}); From f42b7fb34777ccb41f1a2b035df6eb2a2dfc8824 Mon Sep 17 00:00:00 2001 From: HEXXT Date: Thu, 18 Jun 2026 16:31:13 +0100 Subject: [PATCH 10/19] =?UTF-8?q?feat(server):=20Phase=208=20=E2=80=94=20t?= =?UTF-8?q?hin=20server=20v2,=20routes,=20CLI=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add src/server/routes.ts: 9 routes mirroring SDK verbs (search, media, episodes, chapters, sources, episode stream, chapter pages, browse, health). Each handler is ~10 lines: parse params → call SDK → JSON-serialize. AbortController bridges req.close() to SDK cancellation. Add src/server/cli.ts: env-driven process entry — reads PORT, SOURCES_DISABLED, --help flag, starts server via startServerV2(). Add startServerV2({ port, sdk }) to src/server/index.ts: creates SDK (or uses provided), builds routes, listens. Old startServer() kept intact for existing tests. Add bin entry: "anime-sdk": "./dist/server/cli.js" Add ./server export in package.json for startServerV2 import path. tests/e2e/server_v2.test.ts: 5 integration tests — health, search, browse, 400 on missing q, 404 on unknown route. tsc clean, 125 tests pass. --- package.json | 8 ++ src/server/cli.ts | 46 ++++++++ src/server/index.ts | 36 +++++++ src/server/routes.ts | 206 ++++++++++++++++++++++++++++++++++++ tests/e2e/server_v2.test.ts | 73 +++++++++++++ 5 files changed, 369 insertions(+) create mode 100644 src/server/cli.ts create mode 100644 src/server/routes.ts create mode 100644 tests/e2e/server_v2.test.ts diff --git a/package.json b/package.json index 8499ab8..75bfc39 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,9 @@ "scraper", "api" ], + "bin": { + "anime-sdk": "./dist/server/cli.js" + }, "files": [ "dist" ], @@ -30,6 +33,11 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js", + "require": "./dist/server/index.cjs" } }, "scripts": { diff --git a/src/server/cli.ts b/src/server/cli.ts new file mode 100644 index 0000000..a4576f0 --- /dev/null +++ b/src/server/cli.ts @@ -0,0 +1,46 @@ +#!/usr/bin/env node +import { startServerV2 } from './index.js'; +import { createSdk } from '../sdk.js'; +import type { SdkOptions } from '../config.js'; + +function parseEnv(): { port: number; sdkOpts: SdkOptions } { + const port = Number(process.env.PORT ?? 3030); + const disabled = process.env.SOURCES_DISABLED + ? process.env.SOURCES_DISABLED.split(',').map((s) => s.trim()) + : undefined; + return { + port, + sdkOpts: { disabled }, + }; +} + +if (process.argv.includes('--help') || process.argv.includes('-h')) { + process.stdout.write( + [ + 'anime-sdk server', + '', + 'Usage: npx anime-sdk [options]', + '', + 'Env:', + ' PORT=3030 Port to listen on', + ' SOURCES_DISABLED=x Comma-separated source IDs to disable', + '', + ].join('\n'), + ); + process.exit(0); +} + +const { port, sdkOpts } = parseEnv(); +const sdk = createSdk(sdkOpts); +const server = startServerV2({ port, sdk }); + +server.on('listening', () => { + const addr = server.address(); + const p = addr && typeof addr !== 'string' ? addr.port : port; + process.stderr.write(`anime-sdk listening on http://localhost:${p}\n`); +}); + +server.on('error', (err) => { + process.stderr.write(`anime-sdk server error: ${err.message}\n`); + process.exit(1); +}); diff --git a/src/server/index.ts b/src/server/index.ts index fcbc650..00a5f80 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1298,3 +1298,39 @@ function buildOpenApiSpec(args: { paths, }; } + +// ─── 2.0 thin server ───────────────────────────────────────────────────────── + +import { createSdk, Sdk } from '../sdk.js'; +import { buildRoutes, matchRoute } from './routes.js'; + +export interface ServerV2Options { + port?: number; + sdk?: Sdk; +} + +export function startServerV2(opts: ServerV2Options = {}): http.Server { + const sdk = opts.sdk ?? createSdk(); + const routes = buildRoutes(sdk); + + const server = http.createServer((req, res) => { + const u = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + const method = req.method ?? 'GET'; + const match = matchRoute(routes, method, u.pathname); + if (!match) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); + return; + } + const [handler, params] = match; + handler(req, res, params, u.searchParams).catch((e) => { + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (e as Error).message })); + } + }); + }); + + server.listen(opts.port ?? 0); + return server; +} diff --git a/src/server/routes.ts b/src/server/routes.ts new file mode 100644 index 0000000..3c80030 --- /dev/null +++ b/src/server/routes.ts @@ -0,0 +1,206 @@ +import type * as http from 'node:http'; +import type { Sdk } from '../sdk.js'; + +type Handler = ( + req: http.IncomingMessage, + res: http.ServerResponse, + params: Record, + query: URLSearchParams, +) => Promise; + +function json(res: http.ServerResponse, status: number, body: unknown): void { + const data = JSON.stringify(body); + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(data), + }); + res.end(data); +} + +function abort(req: http.IncomingMessage): AbortController { + const ac = new AbortController(); + req.on('close', () => ac.abort()); + return ac; +} + +export function buildRoutes(sdk: Sdk): Array<[string, string, Handler]> { + return [ + [ + 'GET', + '/health', + async (_req, res) => { + json(res, 200, sdk.health()); + }, + ], + + [ + 'GET', + '/search', + async (req, res, _p, query) => { + const q = query.get('q') ?? ''; + const kind = (query.get('kind') ?? 'anime') as 'anime' | 'manga'; + if (!q) return json(res, 400, { error: 'q is required' }); + const ac = abort(req); + const results = await sdk.search(q, { kind, signal: ac.signal }); + json(res, 200, results); + }, + ], + + [ + 'GET', + '/media/:id', + async (req, res, params) => { + const ac = abort(req); + try { + const media = await sdk.info(decodeURIComponent(params.id), { signal: ac.signal }); + json(res, 200, media); + } catch (e) { + json(res, 404, { error: (e as Error).message }); + } + }, + ], + + [ + 'GET', + '/media/:id/episodes', + async (req, res, params, query) => { + const ac = abort(req); + try { + const media = await sdk.info(decodeURIComponent(params.id), { signal: ac.signal }); + const list = await sdk.episodes(media, { + cursor: query.get('cursor') ?? undefined, + signal: ac.signal, + }); + json(res, 200, list); + } catch (e) { + json(res, 404, { error: (e as Error).message }); + } + }, + ], + + [ + 'GET', + '/media/:id/chapters', + async (req, res, params, query) => { + const ac = abort(req); + try { + const media = await sdk.info(decodeURIComponent(params.id), { signal: ac.signal }); + const list = await sdk.chapters(media, { + cursor: query.get('cursor') ?? undefined, + signal: ac.signal, + }); + json(res, 200, list); + } catch (e) { + json(res, 404, { error: (e as Error).message }); + } + }, + ], + + [ + 'GET', + '/media/:id/sources', + async (req, res, params) => { + const ac = abort(req); + try { + const media = await sdk.info(decodeURIComponent(params.id), { signal: ac.signal }); + const sources = await sdk.sources(media, { signal: ac.signal }); + json(res, 200, sources); + } catch (e) { + json(res, 404, { error: (e as Error).message }); + } + }, + ], + + [ + 'GET', + '/episode/:id/stream', + async (req, res, params, query) => { + const ac = abort(req); + try { + const stream = await sdk.stream(decodeURIComponent(params.id), { + language: (query.get('language') ?? 'sub') as 'sub' | 'dub' | 'raw', + quality: query.get('quality') ?? undefined, + adjacency: (query.get('adjacency') ?? 'walk-relations') as + | 'within-media' + | 'walk-relations', + signal: ac.signal, + }); + json(res, 200, stream); + } catch (e) { + json(res, 404, { error: (e as Error).message }); + } + }, + ], + + [ + 'GET', + '/chapter/:id/pages', + async (req, res, params) => { + const ac = abort(req); + try { + const pages = await sdk.pages(decodeURIComponent(params.id), { signal: ac.signal }); + json(res, 200, pages); + } catch (e) { + json(res, 404, { error: (e as Error).message }); + } + }, + ], + + [ + 'GET', + '/browse', + async (req, res, _p, query) => { + const ac = abort(req); + const list = (query.get('list') ?? 'trending') as + | 'trending' + | 'popular' + | 'seasonal' + | 'top'; + const kind = (query.get('kind') ?? 'anime') as 'anime' | 'manga'; + try { + const result = await sdk.browse({ + list, + kind, + page: query.get('page') ? Number(query.get('page')) : undefined, + season: query.get('season') ?? undefined, + year: query.get('year') ? Number(query.get('year')) : undefined, + signal: ac.signal, + }); + json(res, 200, result); + } catch (e) { + json(res, 500, { error: (e as Error).message }); + } + }, + ], + ]; +} + +export function matchRoute( + routes: Array<[string, string, Handler]>, + method: string, + pathname: string, +): [Handler, Record] | null { + for (const [m, pattern, handler] of routes) { + if (m !== method) continue; + const params = matchPattern(pattern, pathname); + if (params !== null) return [handler, params]; + } + return null; +} + +function matchPattern(pattern: string, pathname: string): Record | null { + const patternParts = pattern.split('/'); + const pathParts = pathname.split('/'); + if (patternParts.length !== pathParts.length) return null; + const params: Record = {}; + for (let i = 0; i < patternParts.length; i++) { + const pp = patternParts[i]; + const vp = pathParts[i]; + if (pp.startsWith(':')) { + params[pp.slice(1)] = vp; + } else if (pp !== vp) { + return null; + } + } + return params; +} diff --git a/tests/e2e/server_v2.test.ts b/tests/e2e/server_v2.test.ts new file mode 100644 index 0000000..249bb34 --- /dev/null +++ b/tests/e2e/server_v2.test.ts @@ -0,0 +1,73 @@ +/** + * Integration test for startServerV2 — spawns a real server, hits each route. + * Uses only AniList (fast, no screenshot needed) to keep runtime manageable. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import * as http from 'node:http'; +import { startServerV2 } from '../../src/server/index.js'; +import { createSdk } from '../../src/sdk.js'; + +let server: http.Server; +let baseUrl: string; + +async function getFreePort(): Promise { + return new Promise((resolve) => { + const s = http.createServer(); + s.listen(0, () => { + const port = (s.address() as http.AddressInfo).port; + s.close(() => resolve(port)); + }); + }); +} + +beforeAll(async () => { + const port = await getFreePort(); + const sdk = createSdk({ sources: ['anilist'] }); + server = startServerV2({ port, sdk }); + await new Promise((r) => server.on('listening', r)); + const addr = server.address() as http.AddressInfo; + baseUrl = `http://127.0.0.1:${addr.port}`; +}); + +afterAll(async () => { + await new Promise((r) => server.close(() => r())); +}); + +async function get(path: string) { + const res = await fetch(`${baseUrl}${path}`); + return { status: res.status, body: await res.json() }; +} + +describe('startServerV2 — live integration', () => { + it('GET /health returns source health array', async () => { + const { status, body } = await get('/health'); + expect(status).toBe(200); + expect(Array.isArray(body)).toBe(true); + }); + + it('GET /search?q=frieren&kind=anime returns Media array', async () => { + const { status, body } = await get('/search?q=frieren&kind=anime'); + expect(status).toBe(200); + expect(Array.isArray(body)).toBe(true); + expect((body as any[]).length).toBeGreaterThan(0); + expect((body as any[])[0].kind).toBe('anime'); + expect((body as any[])[0].title.preferred).toBeTruthy(); + }, 30000); + + it('GET /browse?list=trending&kind=anime returns List', async () => { + const { status, body } = await get('/browse?list=trending&kind=anime'); + expect(status).toBe(200); + expect((body as any).items).toBeDefined(); + expect((body as any).items.length).toBeGreaterThan(0); + }, 30000); + + it('GET /search with no q returns 400', async () => { + const { status } = await get('/search'); + expect(status).toBe(400); + }); + + it('GET /unknown returns 404', async () => { + const { status } = await get('/does-not-exist'); + expect(status).toBe(404); + }); +}); From 5a6fc4f67a6306b91a5b81714ceb541c1e871292 Mon Sep 17 00:00:00 2001 From: HEXXT Date: Thu, 18 Jun 2026 16:36:15 +0100 Subject: [PATCH 11/19] =?UTF-8?q?feat(examples):=20Phase=209=20=E2=80=94?= =?UTF-8?q?=20rewrite=20examples=20against=202.0=20SDK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit examples/server.mjs: one-liner — createSdk() + startServerV2() replaces 50-line provider/meta wiring. Zero config, all sources enabled by default. examples/website/src/api.ts: use new server routes (/search, /media/:id, /episode/:id/stream, etc.). Define Media/Episode/Chapter/Stream/Pages types matching the SDK. Drop CONTENT_PROVIDERS/META_PROVIDERS constants, old meta/content split, and duplicated type definitions. Add formatScore() that consumes Score{value,scale} without dividing by 10 everywhere. examples/website/src/pages/Search.tsx: unified search (no Catalogue/Provider toggle). Kind selector (anime/manga) only. Kills bug #1. examples/website/src/pages/Browse.tsx: uses new browse() with List. Drop meta provider picker. Kind selector only. examples/website/src/pages/Media.tsx: uses mediaInfo() + mediaSources(). Shows available sources from SDK (kills bug #4). Score via formatScore(). Drop character/staff/relation sections (not in Media type). examples/website/src/pages/Episodes.tsx: single code path for episodes and chapters. Episode id is opaque — no display-label parsing (kills bug #3). Unified route /stream?epid= or /stream?chid=. examples/website/src/pages/Stream.tsx: uses episodeStream()/chapterPages(). Adjacent prev/next from stream.adjacent (kills bug #8 refetch). origin.host from stream.origin.host (kills bug #7 proxy URL parsing). Manga has no language argument (kills bug #5). --- examples/server.mjs | 51 +-- examples/website/src/api.ts | 311 +++++-------- examples/website/src/pages/Browse.tsx | 88 ++-- examples/website/src/pages/Episodes.tsx | 135 +++--- examples/website/src/pages/Media.tsx | 322 +++----------- examples/website/src/pages/Search.tsx | 176 ++------ examples/website/src/pages/Stream.tsx | 559 +++++------------------- examples/website/tsconfig.tsbuildinfo | 19 +- 8 files changed, 446 insertions(+), 1215 deletions(-) diff --git a/examples/server.mjs b/examples/server.mjs index eed3d1f..1e92535 100644 --- a/examples/server.mjs +++ b/examples/server.mjs @@ -1,48 +1,7 @@ -import { - HttpClient, - startServer, - GogoanimeProvider, - GoyabuProvider, - AllmangaProvider, - AnimeParadiseProvider, - AnikotoProvider, - MegaPlayProvider, - MangadexProvider, - WeebcentralProvider, - MangapillProvider, - AnilistMeta, - MalMeta, - KitsuMeta, - MappingClient, -} from '../dist/index.js'; +import { startServerV2, createSdk } from '../dist/index.js'; -const http = new HttpClient({ timeoutMs: 30000 }); -const mapping = new MappingClient(http); - -const store = new Map(); -const cache = { - get: (key) => store.get(key), - set: (key, value) => store.set(key, value), -}; - -startServer({ - providers: [ - new GogoanimeProvider(http), - new GoyabuProvider(http), - new AllmangaProvider(http), - new AnimeParadiseProvider(http), - new AnikotoProvider(http), - new MegaPlayProvider(http), - new MangadexProvider(http), - new WeebcentralProvider(http), - new MangapillProvider(http), - ], - metaProviders: [ - new AnilistMeta(http, { mappingClient: mapping }), - new MalMeta(http, { mappingClient: mapping }), - new KitsuMeta(http, { mappingClient: mapping }), - ], - port: Number(process.env.PORT ?? 3030), - proxy: true, - cache, +const sdk = createSdk({ + http: { timeoutMs: 30000 }, }); + +startServerV2({ port: Number(process.env.PORT ?? 3030), sdk }); diff --git a/examples/website/src/api.ts b/examples/website/src/api.ts index 08a6a7f..95039fe 100644 --- a/examples/website/src/api.ts +++ b/examples/website/src/api.ts @@ -1,248 +1,161 @@ /// export const API = (import.meta.env.VITE_API_URL as string | undefined) ?? '/api'; -const get = (path: string, params: Record) => +const get = (path: string, params: Record = {}) => fetch(`${API}${path}?${new URLSearchParams(params)}`).then((r) => { if (!r.ok) throw new Error(`${r.status} ${r.statusText}`); return r.json(); }); -// ─── Content provider routes ───────────────────────────────────────────────── +// ─── SDK 2.0 types (mirrors src/types.ts) ──────────────────────────────────── -export const search = (provider: string, q: string) => get('/search', { provider, q }); - -export const content = (provider: string, mediaId: string) => - get('/content', { provider, mediaId }); - -export const stream = (provider: string, unitId: string, language: string) => - get('/stream', { provider, unitId, language }); - -export const tracks = (provider: string, unitId: string, language: string) => - get('/tracks', { provider, unitId, language }); - -// ─── Metadata routes ───────────────────────────────────────────────────────── - -export const metaSearch = (provider: string, q: string) => get('/meta/search', { provider, q }); - -export const metaInfo = (provider: string, id: string) => get('/meta/info', { provider, id }); - -export const metaContent = (provider: string, id: string, contentProvider: string) => - get('/meta/content', { provider, id, contentProvider }); - -export const metaStream = ( - provider: string, - id: string, - episode: number, - contentProvider: string, - language: string, -) => get('/meta/stream', { provider, id, episode: String(episode), contentProvider, language }); - -export const metaBrowse = ( - provider: string, - kind: string, - opts: { catalogType?: string; perPage?: number; season?: string; year?: number } = {}, -) => - get('/meta/browse', { - provider, - kind, - ...(opts.catalogType ? { catalogType: opts.catalogType } : {}), - ...(opts.perPage ? { perPage: String(opts.perPage) } : {}), - ...(opts.season ? { season: opts.season } : {}), - ...(opts.year ? { year: String(opts.year) } : {}), - }); - -// ─── Provider lists ─────────────────────────────────────────────────────────── - -export const CONTENT_PROVIDERS = [ - 'megaplay', - 'allmanga', - 'animeparadise', - 'anikoto', - 'gogoanime', - 'goyabu', - 'mangadex', - 'weebcentral', - 'mangapill', -] as const; - -export const META_PROVIDERS = ['anilist', 'mal', 'kitsu'] as const; - -export type MetaProvider = (typeof META_PROVIDERS)[number]; +export interface MediaTitle { + preferred: string; + english?: string; + romaji?: string; + native?: string; +} -// ─── Content types ──────────────────────────────────────────────────────────── +export interface MediaCover { + url: string; + color?: string; +} -export type Lang = 'sub' | 'dub' | 'raw'; +export interface Score { + value: number; + scale: number; +} -export interface SearchResult { +export interface Media { id: string; - title: string; - thumbnailUrl?: string; - catalogType: string; - providerId: string; - availableLanguages?: Lang[]; + kind: 'anime' | 'manga'; + title: MediaTitle; + cover?: MediaCover; + banner?: string; + score?: Score; year?: number; + season?: string; + status?: string; + format?: string; + episodeCount?: number; + chapterCount?: number; + description?: string; + catalogues: string[]; + playbackSources: string[]; + mappings: { anilist?: number; mal?: number; kitsu?: number; sources?: Record }; } export interface Episode { id: string; - title: string; + mediaId: string; number: number; - availableLanguages?: Lang[]; - thumbnailUrl?: string; - description?: string; + title?: string; + thumbnail?: string; airDate?: string; - isFiller?: boolean; - isRecap?: boolean; + filler?: boolean; + recap?: boolean; + languages: ('sub' | 'dub' | 'raw')[]; + qualities: ('1080p' | '720p' | '480p' | '360p' | 'auto')[]; + source: string; } -export interface SubtitleTrack { +export interface Chapter { + id: string; + mediaId: string; + number: number; + title?: string; + source: string; +} + +export interface Subtitle { url: string; language: string; label: string; - format?: 'vtt' | 'srt' | 'ass'; + format: 'vtt' | 'srt' | 'ass'; } -export interface VideoStream { - sourceUrl: string; - isHLS: boolean; - quality: string; - language?: Lang; +export interface Stream { + url: string; + origin: { host: string; url: string; proxied: boolean }; + isHls: boolean; + qualities: { label: string; url: string }[]; + language: 'sub' | 'dub' | 'raw'; + subtitles: Subtitle[]; headers?: Record; - subtitles?: SubtitleTrack[]; + adjacent: { + prev?: { id: string; number: number }; + next?: { id: string; number: number }; + }; } -export interface MangaStream { - imageUrls: string[]; - headers?: Record; +export interface Pages { + pages: { url: string; origin: { host: string }; width?: number; height?: number }[]; + adjacent: { + prev?: { id: string; number: number }; + next?: { id: string; number: number }; + }; } -export interface ResolvedStream { - type: 'video' | 'manga' | 'live'; - streams?: VideoStream[]; - pages?: MangaStream; +export interface List { + items: T[]; + nextCursor?: string; + total?: number; } -// ─── Metadata types ─────────────────────────────────────────────────────────── - -export interface MetaTitle { - romaji?: string; - english?: string; - native?: string; - userPreferred?: string; -} - -export interface MetaCover { - large?: string; - medium?: string; - color?: string; -} - -export interface MetaSearchResult { +export interface SourceInfo { id: string; - providerId: string; - catalogType: string; - title: MetaTitle; - cover?: MetaCover; - year?: number; - format?: string; - score?: number; - isAdult?: boolean; + status: 'available' | 'incompatible' | 'error'; + episodeCount?: number; + successRate?: number; } -export interface MediaRelation { - id: string; - relationType: string; - catalogType: string; - format?: string; - status?: string; - title: MetaTitle; - cover?: MetaCover; -} +// ─── 2.0 API calls ─────────────────────────────────────────────────────────── -export interface VoiceActor { - id: string; - name: string; - language?: string; - image?: MetaCover; -} +export const search = (q: string, kind: 'anime' | 'manga' = 'anime'): Promise => + get('/search', { q, kind }); -export interface MediaCharacter { - id: string; - name: string; - role?: string; - image?: MetaCover; - voiceActors?: VoiceActor[]; -} +export const mediaInfo = (id: string): Promise => get(`/media/${encodeURIComponent(id)}`); -export interface MediaStaff { - id: string; - name: string; - role?: string; - image?: MetaCover; -} +export const mediaEpisodes = (id: string, cursor?: string): Promise> => + get(`/media/${encodeURIComponent(id)}/episodes`, cursor ? { cursor } : {}); -export interface MediaRecommendation { - id: string; - catalogType: string; - format?: string; - title: MetaTitle; - cover?: MetaCover; - rating?: number; -} +export const mediaChapters = (id: string, cursor?: string): Promise> => + get(`/media/${encodeURIComponent(id)}/chapters`, cursor ? { cursor } : {}); -export interface ExternalLink { - site: string; - url: string; - language?: string; - type?: 'STREAMING' | 'INFO' | 'SOCIAL'; -} +export const mediaSources = (id: string): Promise => + get(`/media/${encodeURIComponent(id)}/sources`); -export interface StreamingEpisode { - number: number; - title?: string; - description?: string; - thumbnail?: string; - airDate?: string; - isFiller?: boolean; - isRecap?: boolean; -} +export const episodeStream = ( + id: string, + language: 'sub' | 'dub' | 'raw' = 'sub', + adjacency?: string, +): Promise => + get(`/episode/${encodeURIComponent(id)}/stream`, { + language, + ...(adjacency ? { adjacency } : {}), + }); -export interface MediaMetadata { - id: string; - providerId: string; - catalogType: string; - title: MetaTitle; - description?: string; - cover?: MetaCover; - banner?: string; - status?: string; - format?: string; - episodeCount?: number; - chapterCount?: number; - durationMinutes?: number; - genres?: string[]; - tags?: string[]; - studios?: string[]; - year?: number; - season?: string; - startDate?: string; - endDate?: string; - score?: number; - trailer?: string; - isAdult?: boolean; - synonyms?: string[]; - relations?: MediaRelation[]; - characters?: MediaCharacter[]; - staff?: MediaStaff[]; - recommendations?: MediaRecommendation[]; - externalLinks?: ExternalLink[]; - streamingEpisodes?: StreamingEpisode[]; -} +export const chapterPages = (id: string): Promise => + get(`/chapter/${encodeURIComponent(id)}/pages`); + +export const browse = ( + list: 'trending' | 'popular' | 'seasonal' | 'top', + kind: 'anime' | 'manga' = 'anime', + opts: { page?: number; season?: string; year?: number } = {}, +): Promise> => + get('/browse', { + list, + kind, + ...(opts.page ? { page: String(opts.page) } : {}), + ...(opts.season ? { season: opts.season } : {}), + ...(opts.year ? { year: String(opts.year) } : {}), + }); -// ─── Helpers ────────────────────────────────────────────────────────────────── +// ─── Helpers ───────────────────────────────────────────────────────────────── -export function preferredTitle(t: MetaTitle): string { - return t.english ?? t.romaji ?? t.userPreferred ?? t.native ?? '(untitled)'; +export function formatScore(s?: Score): string { + if (!s) return 'N/A'; + return ((s.value / s.scale) * 10).toFixed(1); } export function stripHtml(html: string): string { diff --git a/examples/website/src/pages/Browse.tsx b/examples/website/src/pages/Browse.tsx index 5109eff..77703f5 100644 --- a/examples/website/src/pages/Browse.tsx +++ b/examples/website/src/pages/Browse.tsx @@ -6,27 +6,24 @@ import { Button } from '../components/ui/Button'; import { Input } from '../components/ui/Input'; import { Combobox } from '../components/ui/Select'; -const KINDS = ['trending', 'popular', 'seasonal', 'top'] as const; -type Kind = (typeof KINDS)[number]; +const LISTS = ['trending', 'popular', 'seasonal', 'top'] as const; +type ListKind = (typeof LISTS)[number]; const SEASONS = ['WINTER', 'SPRING', 'SUMMER', 'FALL'] as const; const CURRENT_YEAR = new Date().getFullYear(); -function CoverCard({ result, onClick }: { result: api.MetaSearchResult; onClick: () => void }) { - const title = api.preferredTitle(result.title); - const cover = result.cover?.large ?? result.cover?.medium; +function CoverCard({ result, onClick }: { result: api.Media; onClick: () => void }) { const accent = result.cover?.color; - return (
-

{title}

+

+ {result.title.preferred} +

{[result.format, result.year].filter(Boolean).join(' · ')}

@@ -63,20 +57,17 @@ export default function Browse() { const navigate = useNavigate(); const [sp, setSp] = useSearchParams(); - const metaProvider = (sp.get('meta') as api.MetaProvider) || 'anilist'; - const kind = (sp.get('kind') as Kind) || 'trending'; - const catalogType = sp.get('type') || 'ANIME'; + const list = (sp.get('list') as ListKind) || 'trending'; + const kind = (sp.get('kind') as 'anime' | 'manga') || 'anime'; const season = sp.get('season') || ''; const year = sp.get('year') ? Number(sp.get('year')) : undefined; const [searchInput, setSearchInput] = useState(''); - const { data, isFetching, isError, error } = useQuery({ - queryKey: ['browse', metaProvider, kind, catalogType, season, year], + const { data, isFetching, isError, error } = useQuery>({ + queryKey: ['browse', list, kind, season, year], queryFn: () => - api.metaBrowse(metaProvider, kind, { - catalogType, - perPage: 24, + api.browse(list, kind, { season: season || undefined, year, }), @@ -93,11 +84,10 @@ export default function Browse() { const handleSearch = (e: React.FormEvent) => { e.preventDefault(); if (searchInput.trim()) - navigate(`/search?meta=${metaProvider}&q=${encodeURIComponent(searchInput.trim())}`); + navigate(`/search?q=${encodeURIComponent(searchInput.trim())}&kind=${kind}`); }; - const goMedia = (r: api.MetaSearchResult) => - navigate(`/media?meta=${metaProvider}&id=${encodeURIComponent(r.id)}`); + const goMedia = (r: api.Media) => navigate(`/media?id=${encodeURIComponent(r.id)}`); const seasonOptions = [ { value: '', label: 'season' }, @@ -120,7 +110,7 @@ export default function Browse() { onChange={setSearchInput} onSubmit={() => { if (searchInput.trim()) - navigate(`/search?meta=${metaProvider}&q=${encodeURIComponent(searchInput.trim())}`); + navigate(`/search?q=${encodeURIComponent(searchInput.trim())}&kind=${kind}`); }} placeholder="search anime, manga..." /> @@ -131,48 +121,34 @@ export default function Browse() {
- {api.META_PROVIDERS.map((p) => ( - - ))} -
- -
- {(['ANIME', 'MANGA'] as const).map((t) => ( + {(['anime', 'manga'] as const).map((k) => ( ))}
- {KINDS.map((k) => ( + {LISTS.map((l) => ( ))} - {kind === 'seasonal' && ( + {list === 'seasonal' && ( <> - {data.map((r) => ( + {data.items.map((r) => ( goMedia(r)} /> ))}
)} - {data && data.length === 0 && ( + {data && data.items.length === 0 && (

no results for this combination

)}
diff --git a/examples/website/src/pages/Episodes.tsx b/examples/website/src/pages/Episodes.tsx index b0f12b1..f337d36 100644 --- a/examples/website/src/pages/Episodes.tsx +++ b/examples/website/src/pages/Episodes.tsx @@ -6,40 +6,50 @@ export default function Episodes() { const navigate = useNavigate(); const [sp] = useSearchParams(); - const metaProvider = sp.get('meta') || ''; - const metaId = sp.get('id') || ''; + const mediaId = sp.get('id') || ''; + const title = sp.get('title') || mediaId; + const kind = (sp.get('kind') ?? 'anime') as 'anime' | 'manga'; - const provider = sp.get('provider') || ''; - const mediaId = sp.get('mid') || ''; - - const title = sp.get('title') || mediaId || metaId; - const type = sp.get('type') || 'ANIME'; - - const isManga = type === 'MANGA'; + const isManga = kind === 'manga'; const unitLabel = isManga ? 'Ch' : 'EP'; - const isMeta = !!(metaProvider && metaId && provider); + const { + data: episodeList, + isFetching: epFetching, + isError: epError, + error: epErr, + } = useQuery>({ + queryKey: ['episodes', mediaId], + queryFn: () => api.mediaEpisodes(mediaId), + enabled: !isManga && !!mediaId, + }); - const { data, isFetching, isError, error } = useQuery({ - queryKey: isMeta - ? ['meta-content', metaProvider, metaId, provider] - : ['content', provider, mediaId], - queryFn: () => - isMeta ? api.metaContent(metaProvider, metaId, provider) : api.content(provider, mediaId), - enabled: isMeta ? !!(metaProvider && metaId && provider) : !!(provider && mediaId), + const { + data: chapterList, + isFetching: chFetching, + isError: chError, + error: chErr, + } = useQuery>({ + queryKey: ['chapters', mediaId], + queryFn: () => api.mediaChapters(mediaId), + enabled: isManga && !!mediaId, }); - const goStream = (ep: api.Episode) => { - if (isMeta) { + const items = isManga ? chapterList?.items : episodeList?.items; + const isFetching = epFetching || chFetching; + const isError = epError || chError; + const error = epErr || chErr; + + const goStream = (item: api.Episode | api.Chapter) => { + if (isManga) { + const ch = item as api.Chapter; navigate( - `/stream?provider=${provider}&uid=${encodeURIComponent(ep.id)}` + - `&title=${encodeURIComponent(title)}&ep=${encodeURIComponent(`${unitLabel}.${String(ep.number).padStart(3, '0')}`)}&mid=${encodeURIComponent(ep.id)}&type=${type}` + - `&meta=${metaProvider}&metaId=${encodeURIComponent(metaId)}`, + `/stream?chid=${encodeURIComponent(ch.id)}&title=${encodeURIComponent(title)}&mid=${encodeURIComponent(mediaId)}`, ); } else { + const ep = item as api.Episode; navigate( - `/stream?provider=${provider}&uid=${encodeURIComponent(ep.id)}` + - `&title=${encodeURIComponent(title)}&ep=${encodeURIComponent(`${unitLabel}.${String(ep.number).padStart(3, '0')}`)}&mid=${encodeURIComponent(mediaId)}&type=${type}`, + `/stream?epid=${encodeURIComponent(ep.id)}&title=${encodeURIComponent(title)}&mid=${encodeURIComponent(mediaId)}`, ); } }; @@ -48,20 +58,17 @@ export default function Episodes() {
- {isMeta && ( - - ← info - - )} + + ← info +

{title}

- {data && ( + {items && (

- {data.length} {isManga ? 'chapters' : 'episodes'} - {isMeta && via {provider}} + {items.length} {isManga ? 'chapters' : 'episodes'}

)}
@@ -69,59 +76,59 @@ export default function Episodes() { {isFetching &&

fetching...

} {isError &&

{String(error)}

} - {data && ( + {items && (
- {data.map((ep) => { - const hasThumb = !!ep.thumbnailUrl; + {items.map((item) => { + const ep = item as api.Episode; + const ch = item as api.Chapter; + const thumbnail = !isManga ? ep.thumbnail : undefined; return ( ); })} diff --git a/examples/website/src/pages/Media.tsx b/examples/website/src/pages/Media.tsx index c0e0154..f654474 100644 --- a/examples/website/src/pages/Media.tsx +++ b/examples/website/src/pages/Media.tsx @@ -1,76 +1,28 @@ -import { useState } from 'react'; -import { useNavigate, useSearchParams, Link } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import * as api from '../api'; import { Button } from '../components/ui/Button'; -import { Combobox } from '../components/ui/Select'; -import { SectionCollapsible, Expandable } from '../components/ui/Collapsible'; - -function PersonCard({ - name, - role, - image, - sub, -}: { - name: string; - role?: string; - image?: api.MetaCover; - sub?: string; -}) { - const src = image?.large ?? image?.medium; - return ( -
-
- {src ? ( - {name} - ) : ( -
- )} -
-

{name}

- {role &&

{role}

} - {sub &&

{sub}

} -
- ); -} - -function RelationCard({ rel }: { rel: api.MediaRelation }) { - const title = api.preferredTitle(rel.title); - const cover = rel.cover?.medium ?? rel.cover?.large; - return ( -
- {cover ? ( - {title} - ) : ( -
- )} -
-

{rel.relationType}

-

{title}

-

- {[rel.format, rel.status].filter(Boolean).join(' · ')} -

-
-
- ); -} export default function Media() { const navigate = useNavigate(); const [sp] = useSearchParams(); - const metaProvider = sp.get('meta') || 'anilist'; const id = sp.get('id') || ''; - const [contentProvider, setContentProvider] = useState(api.CONTENT_PROVIDERS[0]); - - const { data, isFetching, isError, error } = useQuery({ - queryKey: ['meta-info', metaProvider, id], - queryFn: () => api.metaInfo(metaProvider, id), - enabled: !!(metaProvider && id), + const { data, isFetching, isError, error } = useQuery({ + queryKey: ['media-info', id], + queryFn: () => api.mediaInfo(id), + enabled: !!id, staleTime: 10 * 60 * 1000, }); + const { data: sources } = useQuery({ + queryKey: ['media-sources', id], + queryFn: () => api.mediaSources(id), + enabled: !!id, + staleTime: 5 * 60 * 1000, + }); + if (isFetching) { return (
@@ -87,17 +39,16 @@ export default function Media() { ); } - const title = api.preferredTitle(data.title); - const isManga = data.catalogType === 'MANGA'; + const isManga = data.kind === 'manga'; const unitLabel = isManga ? 'Read' : 'Watch'; + const availableSources = sources?.filter((s) => s.status === 'available') ?? []; + const watch = () => navigate( - `/episodes?meta=${metaProvider}&id=${encodeURIComponent(id)}&provider=${contentProvider}&title=${encodeURIComponent(title)}&type=${data.catalogType}`, + `/episodes?id=${encodeURIComponent(id)}&title=${encodeURIComponent(data.title.preferred)}&kind=${data.kind}`, ); - const providerOptions = api.CONTENT_PROVIDERS.map((p) => ({ value: p, label: p })); - return (
{data.banner && ( @@ -110,10 +61,10 @@ export default function Media() {
- {data.cover?.large ? ( + {data.cover?.url ? ( {title} @@ -123,8 +74,10 @@ export default function Media() {
-

{title}

- {data.title.romaji && data.title.romaji !== title && ( +

+ {data.title.preferred} +

+ {data.title.romaji && data.title.romaji !== data.title.preferred && (

{data.title.romaji}

)} {data.title.native && ( @@ -137,34 +90,17 @@ export default function Media() { {data.year && {data.year}} {data.season && {data.season}} {data.score != null && ( - ★ {(data.score / 10).toFixed(1)} + ★ {api.formatScore(data.score)} )}
{data.episodeCount != null && {data.episodeCount} eps} {data.chapterCount != null && {data.chapterCount} chapters} - {data.durationMinutes != null && {data.durationMinutes}min} - {data.studios?.slice(0, 2).map((s) => ( - {s} - ))}
- {data.genres && data.genres.length > 0 && ( -
- {data.genres.map((g) => ( - - {g} - - ))} -
- )} - {data.description && (

{api.stripHtml(data.description).slice(0, 600)} @@ -176,197 +112,43 @@ export default function Media() { -

- via - -
- {data.trailer && ( - - ▶ trailer ↗ - - )} -
- -
- {data.streamingEpisodes && data.streamingEpisodes.length > 0 && ( - - - {data.streamingEpisodes.map((ep) => ( -
- {ep.thumbnail && ( - {ep.title - )} -
-
- - EP.{String(ep.number).padStart(3, '0')} - - {ep.isFiller && ( - FILLER - )} - {ep.isRecap && ( - RECAP - )} -
- {ep.title && ( -

{ep.title}

- )} - {ep.airDate &&

{ep.airDate}

} -
-
- ))} -
-
- )} - - {data.externalLinks && data.externalLinks.length > 0 && ( - + {availableSources.length > 0 && ( +
+

AVAILABLE ON

- {data.externalLinks.map((link, i) => ( - ( + - {link.site} - {link.language && ({link.language})} - - ))} -
- - )} - - {data.characters && data.characters.length > 0 && ( - - - {data.characters.map((c) => { - const va = - c.voiceActors?.find((v) => v.language === 'Japanese') ?? c.voiceActors?.[0]; - return ( -
- -
- ); - })} -
-
- )} - - {data.staff && data.staff.length > 0 && ( - - - {data.staff.map((s) => ( -
- -
- ))} -
-
- )} - - {data.relations && data.relations.length > 0 && ( - -
- {data.relations.map((r) => ( - + {s.id} + {s.successRate != null && ( + {(s.successRate * 100).toFixed(0)}% + )} + ))}
-
- )} - - {data.recommendations && data.recommendations.length > 0 && ( - - - {data.recommendations.map((r) => { - const recTitle = api.preferredTitle(r.title); - const cover = r.cover?.large ?? r.cover?.medium; - return ( - -
- {cover ? ( - {recTitle} - ) : ( -
- )} - {r.rating != null && ( - - ★ {r.rating} - - )} -
-
-

- {recTitle} -

-
- - ); - })} - - +
)} - {data.tags && data.tags.length > 0 && ( - - - {data.tags.map((t, i) => ( - - {t} - {i < data.tags!.length - 1 ? ',' : ''} - - ))} - - - )} +
+

CATALOGUES

+
+ {data.catalogues.map((c) => ( + + {c} + + ))} +
+
- {data.synonyms && data.synonyms.length > 0 && ( - -
- {data.synonyms.map((s, i) => ( -

- {s} -

- ))} -
-
+ {data.mappings.anilist && ( +
+ AniList ID: {data.mappings.anilist} + {data.mappings.mal ? ` · MAL: ${data.mappings.mal}` : ''} +
)}
diff --git a/examples/website/src/pages/Search.tsx b/examples/website/src/pages/Search.tsx index b9fb739..d208bf3 100644 --- a/examples/website/src/pages/Search.tsx +++ b/examples/website/src/pages/Search.tsx @@ -5,102 +5,53 @@ import * as api from '../api'; import { Button } from '../components/ui/Button'; import { Input } from '../components/ui/Input'; -type Mode = 'meta' | 'content'; - export default function Search() { const navigate = useNavigate(); const [sp, setSp] = useSearchParams(); - const mode = (sp.get('mode') as Mode) || (sp.get('meta') ? 'meta' : 'content'); - const metaProvider = (sp.get('meta') as api.MetaProvider) || 'anilist'; - const contentProvider = sp.get('provider') || api.CONTENT_PROVIDERS[0]; + const kind = (sp.get('kind') ?? 'anime') as 'anime' | 'manga'; const initialQ = sp.get('q') || ''; - const [input, setInput] = useState(initialQ); - const setParam = (key: string, value: string) => - setSp((prev) => { - const next = new URLSearchParams(prev); - next.set(key, value); - return next; - }); - const submit = (e: React.FormEvent) => { e.preventDefault(); setSp((prev) => { const next = new URLSearchParams(prev); next.set('q', input); - next.set('mode', mode); return next; }); }; - const metaQuery = useQuery({ - queryKey: ['meta-search', metaProvider, initialQ], - queryFn: () => api.metaSearch(metaProvider, initialQ), - enabled: mode === 'meta' && !!initialQ, - }); - - const contentQuery = useQuery({ - queryKey: ['search', contentProvider, initialQ], - queryFn: () => api.search(contentProvider, initialQ), - enabled: mode === 'content' && !!initialQ, + const { data, isFetching, isError, error } = useQuery({ + queryKey: ['search', kind, initialQ], + queryFn: () => api.search(initialQ, kind), + enabled: !!initialQ, }); - const isFetching = mode === 'meta' ? metaQuery.isFetching : contentQuery.isFetching; - const isError = mode === 'meta' ? metaQuery.isError : contentQuery.isError; - const error = mode === 'meta' ? metaQuery.error : contentQuery.error; - - const goMedia = (r: api.MetaSearchResult) => - navigate(`/media?meta=${metaProvider}&id=${encodeURIComponent(r.id)}`); - - const goEpisodes = (r: api.SearchResult) => - navigate( - `/episodes?provider=${contentProvider}&mid=${encodeURIComponent(r.id)}&title=${encodeURIComponent(r.title)}&type=${r.catalogType}`, - ); + const goMedia = (m: api.Media) => navigate(`/media?id=${encodeURIComponent(m.id)}`); return (
- {(['meta', 'content'] as const).map((m) => ( + {(['anime', 'manga'] as const).map((k) => ( ))}
-
- {mode === 'meta' - ? api.META_PROVIDERS.map((p) => ( - - )) - : api.CONTENT_PROVIDERS.map((p) => ( - - ))} -
-
{ const next = new URLSearchParams(prev); next.set('q', input); - next.set('mode', mode); return next; }); }} @@ -123,77 +73,41 @@ export default function Search() { {isFetching &&

fetching...

} {isError &&

{String(error)}

} - {mode === 'meta' && metaQuery.data && ( + {data && (
- RESULTS ({metaQuery.data.length}) + RESULTS ({data.length})
- {metaQuery.data.map((r) => { - const title = api.preferredTitle(r.title); - const cover = r.cover?.medium ?? r.cover?.large; - return ( - - ); - })} -
- )} - - {mode === 'content' && contentQuery.data && ( -
-
- RESULTS ({contentQuery.data.length}) -
- {contentQuery.data.map((r) => ( + {data.map((r) => ( ))}
diff --git a/examples/website/src/pages/Stream.tsx b/examples/website/src/pages/Stream.tsx index 1f4af44..5d5eac1 100644 --- a/examples/website/src/pages/Stream.tsx +++ b/examples/website/src/pages/Stream.tsx @@ -4,183 +4,37 @@ import { useQuery } from '@tanstack/react-query'; import Hls from 'hls.js'; import * as api from '../api'; import { Combobox } from '../components/ui/Select'; -import { SectionCollapsible } from '../components/ui/Collapsible'; -type DownloadPhase = 'idle' | 'active' | 'done' | 'error'; - -function DownloadButton({ - provider, - unitId, - language, - type, -}: { - provider: string; - unitId: string; - language: string; - type: 'video' | 'manga'; -}) { - const [phase, setPhase] = useState('idle'); - const [label, setLabel] = useState(''); - const esRef = useRef(null); - - const stop = () => { - esRef.current?.close(); - esRef.current = null; - }; - - useEffect(() => stop, []); - - const start = () => { - if (phase === 'active') { - stop(); - setPhase('idle'); - return; - } - - const progressPath = - type === 'video' - ? `/download/video/progress?provider=${provider}&unitId=${encodeURIComponent(unitId)}&language=${language}` - : `/download/manga/chapter/progress?provider=${provider}&unitId=${encodeURIComponent(unitId)}`; - const filePath = type === 'video' ? '/download/video/file' : '/download/manga/chapter/file'; - - setPhase('active'); - setLabel('connecting…'); - - const es = new EventSource(`${api.API}${progressPath}`); - esRef.current = es; - - es.onmessage = (e) => { - const data = JSON.parse(e.data) as Record; - if (data.type === 'progress') { - if (type === 'manga') { - setLabel(`${data.downloaded}/${data.total} pages`); - } else { - const detail = data.detail as string | undefined; - setLabel(detail ?? (data.phase as string)); - } - } else if (data.type === 'complete') { - stop(); - setPhase('done'); - setLabel(''); - const a = document.createElement('a'); - a.href = `${api.API}${filePath}?token=${data.token}`; - a.click(); - setTimeout(() => setPhase('idle'), 3000); - } else if (data.type === 'error') { - stop(); - setPhase('error'); - setLabel((data.message as string | undefined) ?? 'failed'); - setTimeout(() => setPhase('idle'), 4000); - } - }; - - es.onerror = () => { - stop(); - setPhase('error'); - setLabel('connection failed'); - setTimeout(() => setPhase('idle'), 3000); - }; - }; - - if (phase === 'idle') { - return ( - - ); - } - - if (phase === 'done') { - return SAVED; - } - - if (phase === 'error') { - return ( - - ); - } - - return ( -
- {label} - -
- ); -} - -function Player({ - stream, - subtitles, - langUI, -}: { - stream: api.VideoStream; - subtitles: api.SubtitleTrack[]; - langUI?: React.ReactNode; -}) { +function Player({ stream, langUI }: { stream: api.Stream; langUI?: React.ReactNode }) { const ref = useRef(null); const [playerError, setPlayerError] = useState(null); - const [hlsSubTracks, setHlsSubTracks] = useState<{ id: number; name: string; lang: string }[]>( - [], - ); - const [activeSub, setActiveSub] = useState(-1); + const [activeSub, setActiveSub] = useState(stream.subtitles.length > 0 ? 0 : -1); const hlsRef = useRef(undefined); - const externalSubs = subtitles; useEffect(() => { const v = ref.current; if (!v) return; setPlayerError(null); - setHlsSubTracks([]); - setActiveSub(externalSubs.length > 0 ? 0 : -1); let hls: Hls | undefined; - if (stream.isHLS) { + if (stream.isHls) { if (Hls.isSupported()) { hls = new Hls({ enableWorker: false }); - hls.subtitleDisplay = true; hls.on(Hls.Events.ERROR, (_, d) => { if (d.fatal) setPlayerError(`HLS error: ${d.details}`); }); - hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_, d) => { - const tracks = (d.subtitleTracks ?? []).map((t: any) => ({ - id: t.id, - name: t.name ?? t.lang ?? `Track ${t.id}`, - lang: t.lang ?? '', - })); - setHlsSubTracks(tracks); - if (tracks.length > 0 && externalSubs.length === 0) { - hls!.subtitleTrack = 0; - setActiveSub(1000); - } else { - hls!.subtitleTrack = -1; - } - }); - hls.loadSource(stream.sourceUrl); + hls.loadSource(stream.url); hls.attachMedia(v); hlsRef.current = hls; v.play().catch(() => {}); } else if (v.canPlayType('application/vnd.apple.mpegurl')) { - v.src = stream.sourceUrl; + v.src = stream.url; v.play().catch(() => {}); } else { setPlayerError('HLS not supported in this browser'); } } else { - v.src = stream.sourceUrl; + v.src = stream.url; v.play().catch(() => {}); } @@ -189,7 +43,7 @@ function Player({ hlsRef.current = undefined; v.src = ''; }; - }, [stream.sourceUrl, stream.isHLS, externalSubs.length]); + }, [stream.url, stream.isHls]); useEffect(() => { const v = ref.current; @@ -198,16 +52,7 @@ function Player({ for (let i = 0; i < textTracks.length; i++) { textTracks[i].mode = activeSub === i ? 'showing' : 'disabled'; } - }, [activeSub, externalSubs.length]); - - const selectSub = (key: number) => { - setActiveSub(key); - if (hlsRef.current) { - hlsRef.current.subtitleTrack = key >= 1000 ? key - 1000 : -1; - } - }; - - const hasSubtitleUI = hlsSubTracks.length > 0 || externalSubs.length > 0; + }, [activeSub]); if (playerError) { return ( @@ -225,42 +70,51 @@ function Player({ crossOrigin="anonymous" className="border-base-200 bg-base-0 mb-4 aspect-video w-full border" > - {externalSubs.map((s, i) => ( - + {stream.subtitles.map((s, i) => ( + ))} {langUI} - {hasSubtitleUI && ( + {stream.subtitles.length > 0 && (
SUB selectSub(Number(v))} + onValueChange={(v) => setActiveSub(Number(v))} options={[ { value: '-1', label: 'off' }, - ...externalSubs.map((s, i) => ({ value: String(i), label: s.label })), - ...hlsSubTracks.map((t) => ({ value: String(1000 + t.id), label: t.name })), + ...stream.subtitles.map((s, i) => ({ value: String(i), label: s.label })), ]} />
)} + {stream.qualities.length > 1 && ( +
+ QUALITY + {stream.qualities.map((q) => ( + + {q.label} + + ))} +
+ )}
); } -function MangaReader({ pages }: { pages: api.MangaStream }) { +function MangaReader({ pages }: { pages: api.Pages }) { return (
- {pages.imageUrls.map((url, i) => ( + {pages.pages.map((p, i) => ( {`Page('sub'); - const [activeIdx, setActiveIdx] = useState(0); - const [showEpisodes, setShowEpisodes] = useState(false); - const { data: episodes } = useQuery({ - queryKey: ['content', provider, mediaId], - queryFn: () => api.content(provider, mediaId), - enabled: !!(provider && mediaId), - staleTime: 5 * 60 * 1000, + const isManga = !!chapterId; + const [lang, setLang] = useState<'sub' | 'dub' | 'raw'>('sub'); + + const { + data: streamData, + isFetching: streamFetching, + isError: streamError, + error: streamErr, + } = useQuery({ + queryKey: ['stream', episodeId, lang], + queryFn: () => api.episodeStream(episodeId, lang), + enabled: !!episodeId, }); - const currentEpNum = epLabel ? parseFloat(epLabel.replace(/^[A-Z]+\./i, '')) : null; - const currentIdx = episodes?.findIndex((e) => e.number === currentEpNum) ?? -1; - const currentEpisode = currentIdx >= 0 ? episodes![currentIdx] : null; - const availableLangs = currentEpisode?.availableLanguages ?? ['sub']; - - useEffect(() => { - if (availableLangs.length > 0 && !availableLangs.includes(lang)) { - setLang(availableLangs[0]); - } - }, [availableLangs, lang]); - - const { data, isFetching, isError, error } = useQuery({ - queryKey: ['stream', provider, unitId, lang], - queryFn: () => api.stream(provider, unitId, lang), - enabled: !!(provider && unitId), + const { + data: pagesData, + isFetching: pagesFetching, + isError: pagesError, + error: pagesErr, + } = useQuery({ + queryKey: ['pages', chapterId], + queryFn: () => api.chapterPages(chapterId), + enabled: !!chapterId, }); - const streams = data?.type === 'video' ? (data.streams ?? []) : []; - const active = streams[activeIdx] ?? null; - const subtitles = active?.subtitles ?? []; + const isFetching = streamFetching || pagesFetching; + const isError = streamError || pagesError; + const error = streamErr || pagesErr; - const prevEp = currentIdx > 0 ? episodes![currentIdx - 1] : null; - const nextEp = - currentIdx >= 0 && currentIdx < (episodes?.length ?? 0) - 1 ? episodes![currentIdx + 1] : null; + const goAdjacentEpisode = (adj: { id: string; number: number }) => { + navigate( + `/stream?epid=${encodeURIComponent(adj.id)}&title=${encodeURIComponent(title)}&mid=${encodeURIComponent(mediaId)}`, + ); + }; - const goEpisode = (ep: api.Episode) => { - const base = - `/stream?provider=${provider}&uid=${encodeURIComponent(ep.id)}` + - `&title=${encodeURIComponent(title)}&ep=${encodeURIComponent(`${unitPrefix}.${String(ep.number).padStart(3, '0')}`)}&mid=${encodeURIComponent(mediaId)}&type=${type}`; + const goAdjacentChapter = (adj: { id: string; number: number }) => { navigate( - metaProvider && metaId - ? `${base}&meta=${metaProvider}&metaId=${encodeURIComponent(metaId)}` - : base, + `/stream?chid=${encodeURIComponent(adj.id)}&title=${encodeURIComponent(title)}&mid=${encodeURIComponent(mediaId)}`, ); }; - const infoHref = - metaProvider && metaId ? `/media?meta=${metaProvider}&id=${encodeURIComponent(metaId)}` : null; + const adjacent = streamData?.adjacent ?? pagesData?.adjacent; + const prev = adjacent?.prev; + const next = adjacent?.next; - useEffect(() => { - setActiveIdx(0); - }, [unitId, lang]); + const availableLangs: ('sub' | 'dub' | 'raw')[] = streamData ? [streamData.language] : ['sub']; return (
- {infoHref && ( + {mediaId && (
- - ← {title} + + ← {title || 'back'} - {epLabel && / {epLabel}}
)} +
{isFetching && (
@@ -364,11 +210,11 @@ export default function Stream() {

{String(error)}

)} - {data?.type === 'video' && active && ( + + {streamData && ( 1 && (
@@ -387,221 +233,38 @@ export default function Stream() { } /> )} - {data?.type === 'manga' && data.pages && ( - <> - -
- -
- {availableLangs.length > 1 && ( -
- LANG - {availableLangs.map((l) => ( - - ))} -
- )} - - )} -
- {episodes && ( -
-
- -
- - -
-
+ {pagesData && } +
- {showEpisodes && ( -
- {episodes.map((ep) => { - const isCurrent = ep.number === currentEpNum; - return ( - - ); - })} -
- )} + {/* Origin info (replaces proxy URL parsing) */} + {streamData && ( +
+ SOURCE + {streamData.origin.host} + {streamData.origin.proxied && · proxied}
)} - {streams.length > 0 && - (streams.length > 2 ? ( - -
- -
- {streams.map((s, i) => { - let displayUrl = s.sourceUrl; - try { - const u = new URL(s.sourceUrl); - if (u.pathname === '/proxy' && u.searchParams.has('url')) { - const targetUrl = new URL(u.searchParams.get('url')!); - displayUrl = targetUrl.hostname; - } else { - displayUrl = u.hostname; - } - } catch {} - - return ( -
- - e.stopPropagation()} - className="text-base-350 hover:text-base-600 mt-0.5 shrink-0 text-xs transition-colors" - > - {s.isHLS ? '↗' : '↓'} - -
- ); - })} -
- ) : ( -
-
- - SOURCES ({streams.length}) - - -
- {streams.map((s, i) => { - let displayUrl = s.sourceUrl; - try { - const u = new URL(s.sourceUrl); - if (u.pathname === '/proxy' && u.searchParams.has('url')) { - const targetUrl = new URL(u.searchParams.get('url')!); - displayUrl = targetUrl.hostname; - } else { - displayUrl = u.hostname; - } - } catch {} - - return ( -
- - e.stopPropagation()} - className="text-base-350 hover:text-base-600 mt-0.5 shrink-0 text-xs transition-colors" - > - {s.isHLS ? '↗' : '↓'} - -
- ); - })} -
- ))} + {/* Prev / Next navigation from adjacent */} +
+
+ + +
+
); } diff --git a/examples/website/tsconfig.tsbuildinfo b/examples/website/tsconfig.tsbuildinfo index ad87b91..e4a55ee 100644 --- a/examples/website/tsconfig.tsbuildinfo +++ b/examples/website/tsconfig.tsbuildinfo @@ -1 +1,18 @@ -{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/components/layout.tsx","./src/pages/episodes.tsx","./src/pages/search.tsx","./src/pages/stream.tsx"],"version":"5.7.3"} \ No newline at end of file +{ + "root": [ + "./src/app.tsx", + "./src/api.ts", + "./src/main.tsx", + "./src/components/layout.tsx", + "./src/components/ui/button.tsx", + "./src/components/ui/collapsible.tsx", + "./src/components/ui/input.tsx", + "./src/components/ui/select.tsx", + "./src/pages/browse.tsx", + "./src/pages/episodes.tsx", + "./src/pages/media.tsx", + "./src/pages/search.tsx", + "./src/pages/stream.tsx" + ], + "version": "5.7.3" +} From e2e28faccf8f61ea3877dac5136c3fec3015a213 Mon Sep 17 00:00:00 2001 From: HEXXT Date: Thu, 18 Jun 2026 16:38:25 +0100 Subject: [PATCH 12/19] =?UTF-8?q?feat(cleanup):=20Phase=2010=20=E2=80=94?= =?UTF-8?q?=20trim=20public=20surface,=20delete=20obsolete=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update src/index.ts: 2.0 surface is now the primary export (createSdk, Sdk, types, AniError, AniErrorCode, SdkOptions, startServerV2). Legacy 1.x exports moved to a clearly-marked backward-compat section — old providers, meta classes, transport classes, and utilities kept temporarily for the proxy/download tests; these will be removed when those tests are updated to the 2.0 API. Delete tests/e2e/concurrencyCap.test.ts (tests BaseProvider.maxConcurrency which is a 1.x-only concept). Delete tests/e2e/metaIntegration.test.ts (tests the old BaseMetadataProvider + MappingClient + BaseProvider chain; replaced by new source integration tests). tsc clean, 125 unit tests pass. --- src/index.ts | 77 ++++++++++++++----------------- tests/e2e/concurrencyCap.test.ts | 74 ----------------------------- tests/e2e/metaIntegration.test.ts | 59 ----------------------- 3 files changed, 35 insertions(+), 175 deletions(-) delete mode 100644 tests/e2e/concurrencyCap.test.ts delete mode 100644 tests/e2e/metaIntegration.test.ts diff --git a/src/index.ts b/src/index.ts index a09704e..bab7446 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,52 +15,45 @@ export type { Subtitle, } from './types.js'; export { AniError, AniErrorCode } from './errors.js'; -export type { AniErrorCode as AniErrorCodeType } from './errors.js'; export type { SdkOptions } from './config.js'; -// ── Legacy 1.x surface (kept for backwards compat; removed in Phase 10) ────── -// Types -export * from './types/index.js'; - -// Transport -export * from './transport/http.js'; -export * from './transport/hlsUtils.js'; -export * from './transport/dom.js'; -export * from './transport/rateLimiter.js'; -export * from './transport/retry.js'; -export * from './transport/transport.js'; - -// Extractors -export * from './extractors/BaseExtractor.js'; -export * from './extractors/VidstreamingExtractor.js'; -export * from './extractors/Mp4UploadExtractor.js'; -export * from './extractors/GenericHlsExtractor.js'; -export * from './extractors/BloggerExtractor.js'; +// ── Server ──────────────────────────────────────────────────────────────────── +export { startServerV2 } from './server/index.js'; +// Legacy server (kept for existing consumers that use the old API) +export { startServer } from './server/index.js'; +export type { ServerOptions } from './server/index.js'; +export type { ServerV2Options } from './server/index.js'; -// Base -export * from './providers/BaseProvider.js'; - -// Providers -export * from './providers/AllmangaProvider.js'; -export * from './providers/GogoanimeProvider.js'; -export * from './providers/GoyabuProvider.js'; -export * from './providers/AnikotoProvider.js'; -export * from './providers/MegaPlayProvider.js'; -export * from './providers/AnimeParadiseProvider.js'; -export * from './providers/MangadexProvider.js'; -export * from './providers/WeebcentralProvider.js'; -export * from './providers/MangapillProvider.js'; +// ── Downloads ───────────────────────────────────────────────────────────────── +export * from './download/index.js'; -// Utilities +// ── Backward-compat 1.x exports (deprecated; removed when old tests updated) ── +// Old types +export * from './types/index.js'; +// Old transport (now internal, but kept for tests that import directly) +export { HttpClient } from './internal/http.js'; +export { HlsUtils } from './internal/hls.js'; +export { DomRegistry, BrowserDomParser } from './internal/dom.js'; +export { RateLimiter } from './internal/rateLimiter.js'; +export { withRetry, HttpRetryableError, parseRetryAfter } from './internal/retry.js'; +export { CurlFallbackTransport, FetchTransport } from './internal/transport.js'; +// Old providers (kept for proxy tests + old server test) +export { AllmangaProvider } from './providers/AllmangaProvider.js'; +export { GogoanimeProvider } from './providers/GogoanimeProvider.js'; +export { GoyabuProvider } from './providers/GoyabuProvider.js'; +export { AnikotoProvider } from './providers/AnikotoProvider.js'; +export { MegaPlayProvider } from './providers/MegaPlayProvider.js'; +export { AnimeParadiseProvider } from './providers/AnimeParadiseProvider.js'; +export { MangadexProvider } from './providers/MangadexProvider.js'; +export { WeebcentralProvider } from './providers/WeebcentralProvider.js'; +export { MangapillProvider } from './providers/MangapillProvider.js'; +export { BaseProvider } from './providers/BaseProvider.js'; +// Old meta (kept for server test) +export { AnilistMeta } from './meta/AnilistMeta.js'; +export { MalMeta } from './meta/MalMeta.js'; +export { KitsuMeta } from './meta/KitsuMeta.js'; +export { MappingClient } from './meta/MappingClient.js'; +// Old utils (kept for server which uses urn helpers) export * from './utils/crypto.js'; export * from './utils/subtitles.js'; export * from './utils/urn.js'; - -// Metadata layer -export * from './meta/index.js'; - -// Download -export * from './download/index.js'; - -// HTTP server -export * from './server/index.js'; diff --git a/tests/e2e/concurrencyCap.test.ts b/tests/e2e/concurrencyCap.test.ts deleted file mode 100644 index 7baa2ab..0000000 --- a/tests/e2e/concurrencyCap.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Live test: BaseProvider.maxConcurrency caps in-flight calls. - * - * Uses a real http.Server upstream that records the number of overlapping - * requests in flight at any moment, then drives the provider with a burst - * of parallel calls. Asserts the upstream never sees more than the cap. - */ -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import * as http from 'node:http'; -import { BaseProvider, CallOptions } from '../../src/providers/BaseProvider.js'; -import { HttpClient } from '../../src/transport/http.js'; -import { - ContentLanguage, - IContentUnit, - IMediaSearchResult, - MediaCatalogType, - ResolvedMediaStream, -} from '../../src/types/index.js'; - -let server: http.Server; -let baseUrl: string; -let inFlight = 0; -let maxInFlight = 0; -const reset = () => { - inFlight = 0; - maxInFlight = 0; -}; - -beforeAll(async () => { - server = http.createServer(async (_req, res) => { - inFlight += 1; - if (inFlight > maxInFlight) maxInFlight = inFlight; - // hold the connection long enough for parallel requests to overlap - await new Promise((r) => setTimeout(r, 60)); - inFlight -= 1; - res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true })); - }); - await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); - const addr = server.address(); - if (!addr || typeof addr === 'string') throw new Error('no address'); - baseUrl = `http://127.0.0.1:${addr.port}`; -}); - -afterAll(async () => { - await new Promise((resolve) => server.close(() => resolve())); -}); - -class CappedProvider extends BaseProvider { - public readonly id = 'capped'; - public readonly supportedTypes: MediaCatalogType[] = ['ANIME']; - public readonly maxConcurrency = 2; - protected async searchRaw(_q: string, options: CallOptions = {}): Promise { - await this.http.get(`${baseUrl}/q`, { signal: options.signal }); - return [{ id: 'x', title: 'x', catalogType: 'ANIME', providerId: this.id }]; - } - protected async fetchContentUnitsRaw(): Promise { - return []; - } - protected async resolveStreamRaw(_u: string, _l?: ContentLanguage): Promise { - throw new Error('not implemented'); - } -} - -describe('BaseProvider — concurrency cap', () => { - it('caps parallel in-flight calls to maxConcurrency', async () => { - reset(); - const http = new HttpClient({ disableRateLimit: true, retry: false }); - const provider = new CappedProvider(http); - // 10 parallel calls; only 2 should hit the upstream simultaneously. - await Promise.all(Array.from({ length: 10 }, () => provider.search('q'))); - expect(maxInFlight).toBeLessThanOrEqual(2); - expect(maxInFlight).toBeGreaterThan(0); - }, 30_000); -}); diff --git a/tests/e2e/metaIntegration.test.ts b/tests/e2e/metaIntegration.test.ts deleted file mode 100644 index 92dd327..0000000 --- a/tests/e2e/metaIntegration.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * End-to-end metadata → content → stream pipeline. - * - * Demonstrates the meta layer's value proposition: - * 1. Search a catalogue (AniList) for a title. - * 2. Pull full metadata + enrichments. - * 3. Use the same AniList ID to list episodes on a content provider - * (`AllmangaProvider`) — the SDK resolves the cross-source mapping - * under the hood. - * 4. Verify the per-episode metadata from AniList's `streamingEpisodes` - * is folded onto the content unit list. - */ -import { describe, expect, it } from 'vitest'; -import { HttpClient } from '../../src/transport/http.js'; -import { AnilistMeta } from '../../src/meta/AnilistMeta.js'; -import { MappingClient } from '../../src/meta/MappingClient.js'; -import { AllmangaProvider } from '../../src/providers/AllmangaProvider.js'; - -describe('Metadata → content integration (live)', () => { - it('fetches AniList metadata for Cowboy Bebop and lists episodes on AllManga via mapping', async () => { - const http = new HttpClient({ timeoutMs: 30_000 }); - const mapping = new MappingClient(http); - const meta = new AnilistMeta(http, { mappingClient: mapping }); - const allmanga = new AllmangaProvider(http); - - // 1. Pull AniList metadata + enrichments. - const info = await meta.fetchMediaInfo('anilist:1'); - expect(info.title.english).toBe('Cowboy Bebop'); - expect(info.episodeCount).toBe(26); - // AniList carries per-episode metadata for Cowboy Bebop. - expect((info.streamingEpisodes ?? []).length).toBeGreaterThan(0); - - // 2. List episodes on AllManga — mapping is resolved automatically. - const units = await meta.fetchContentUnits('anilist:1', allmanga); - expect(units.length).toBeGreaterThan(0); - - // 3. The meta layer folds AniList streamingEpisodes titles/thumbnails - // onto the content units that share an episode number. - const enriched = units.filter((u) => u.thumbnailUrl); - expect(enriched.length).toBeGreaterThan(0); - // Ep 1's title should not be the bare "Episode 1" — AniList carries - // "Asteroid Blues". - const ep1 = units.find((u) => u.number === 1); - expect(ep1).toBeDefined(); - expect(ep1!.title.toLowerCase()).toContain('asteroid'); - }, 90_000); - - it('lookupByMapping shortcut: MegaPlayProvider returns the AniList ID directly', async () => { - const http = new HttpClient({ timeoutMs: 15_000 }); - const { MegaPlayProvider } = await import('../../src/providers/MegaPlayProvider.js'); - const megaplay = new MegaPlayProvider(http); - // MegaPlayProvider opts into the lookupByMapping fast path — its - // media ID *is* the AniList ID. No network calls should be needed. - const raw = await megaplay.lookupByMapping!({ anilist: 1 }); - expect(raw).toBe('1'); - const none = await megaplay.lookupByMapping!({}); - expect(none).toBeNull(); - }); -}); From c2c96d726ae6bc9e26c0d5c3c31d5935d20edc40 Mon Sep 17 00:00:00 2001 From: HEXXT Date: Thu, 18 Jun 2026 16:41:09 +0100 Subject: [PATCH 13/19] =?UTF-8?q?docs(2.0):=20Phase=2011=20=E2=80=94=20REA?= =?UTF-8?q?DME,=20CLAUDE.md,=20changeset=20for=20v2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README.md: rewritten against 2.0 API with three code samples (search→stream, server one-liner, custom config), error handling, cancellation, source table, server routes table. CLAUDE.md: updated to reflect new architecture — single Source interface, internal/id.ts for opaque IDs, MappingClient is private, no DOMParser shim, Registry+Sdk+ProgressiveResult layer. Updated source-addition guide. .changeset/sdk-2-0.md: major bump with migration table (old → new API). Build passes (272 KB ESM). 125 unit tests pass. tsc clean. --- .changeset/sdk-2-0.md | 39 +++++ CLAUDE.md | 115 +++++++------- README.md | 347 +++++++++++++----------------------------- 3 files changed, 195 insertions(+), 306 deletions(-) create mode 100644 .changeset/sdk-2-0.md diff --git a/.changeset/sdk-2-0.md b/.changeset/sdk-2-0.md new file mode 100644 index 0000000..0c43138 --- /dev/null +++ b/.changeset/sdk-2-0.md @@ -0,0 +1,39 @@ +--- +'anime-sdk': major +--- + +# anime-sdk 2.0 + +## Breaking changes + +The public API has been completely redesigned. Old exports (`BaseProvider`, `BaseMetadataProvider`, `AllmangaProvider`, `AnilistMeta`, `HttpClient`, URN helpers, etc.) are kept for one release cycle under the `// Backward-compat 1.x` section of `src/index.ts` and will be removed in 3.0. + +### Migration table + +| 1.x | 2.0 | +| -------------------------------------------------------------- | ----------------------------------------------------------- | +| `new AllmangaProvider(http)` / `new AnilistMeta(http)` | `createSdk()` | +| `provider.search(q)` → `IMediaSearchResult[]` | `sdk.search(q)` → `ProgressiveResult` | +| `provider.fetchContentUnits(mediaId)` → `IContentUnit[]` | `sdk.episodes(media)` → `List` | +| `provider.resolveStream(unitId, lang)` → `ResolvedMediaStream` | `sdk.stream(episode)` → `Stream` | +| `buildUrn` / `parseUrn` / `strictUnwrapUrn` | `encodeId` / `decodeId` (internal; ids are opaque) | +| `startServer({ providers, metaProviders })` | `startServerV2({ port, sdk })` or `startServer(...)` (kept) | +| `IVideoPayload.sourceUrl` | `Stream.url` | +| `IVideoPayload.isHLS` | `Stream.isHls` | +| Score as `number` (0–100) | `Score { value, scale }` | +| `IMediaTitle.userPreferred` | `MediaTitle.preferred` | +| `IMediaImage.large` | `MediaCover.url` | + +## New features + +- **`createSdk()`**: zero-config factory. All 12 sources, built-in DOM parser, sane rate limits. +- **Unified `Media`/`Episode`/`Chapter` types**: plain POJOs, `JSON.stringify`-safe, opaque `id` fields. +- **`ProgressiveResult`**: `AsyncIterable` + `PromiseLike` for search results. +- **`Stream.adjacent`**: prev/next episode IDs without a second fetch. +- **`Stream.origin`**: `{ host, url, proxied }` — no proxy URL parsing on the consumer side. +- **`Score { value, scale }`**: units carried; no more `(score / 10).toFixed(1)` everywhere. +- **`sdk.sources(media)`**: ranked list of playable providers — safe "Watch via" dropdown. +- **`npx anime-sdk`**: zero-install server. `PORT`, `SOURCES_DISABLED` env vars. +- **Manga has no language axis**: `sdk.pages(chapter)` — no `language` argument. +- **`AniError` + `AniErrorCode`**: structured error type for branching without string matching. +- **AbortSignal on every async call**. diff --git a/CLAUDE.md b/CLAUDE.md index 63232b9..515d0a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,69 +13,64 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Requires Node 20+ and `ffmpeg` on `PATH` (E2E suite shells out to it). -## Architecture +## Architecture (v2.0) -The SDK has four layers, all wired around a single `HttpClient`: +The SDK has five layers: -**1. Transport (`src/transport/`)**: site-agnostic plumbing. +**1. Internal plumbing (`src/internal/`)**: private, never exported. -- `HttpClient` wraps `fetch`, layered with a per-host rate limiter (`RateLimiter`, with built-in policies for AniList/Jikan/Kitsu/MALSync/Anify/arm-server), an exponential-backoff retry that honors `Retry-After` (`withRetry` + `HttpRetryableError`), and a curl-based fallback transport. The fallback is encapsulated behind the `HttpTransport` interface (`CurlFallbackTransport` default, `FetchTransport` for runtimes without `child_process`). `AbortSignal` is composed end-to-end: the caller's signal plus the SDK's timeout signal both abort the in-flight fetch. -- Rate limit + retry are on by default with sensible policies for the bundled catalogue APIs (`DEFAULT_RATE_LIMITS`, `DEFAULT_RETRY_STATUSES`). Disable per-instance via `disableRateLimit: true` and `retry: false`. -- `HttpClient` supports two proxy routing modes (`prepend` puts the proxy in front of `host/path`; `query` passes the URL as a query param): `requestUrl(url)` is the single chokepoint for that rewrite. -- `DomRegistry` is a global single-parser registry. `BrowserDomParser` works in browsers; in Node, `dom.ts` automatically registers `linkedom` (a direct dependency) as the `globalThis.DOMParser` shim on import — consumers and tests no longer need to do this manually. Providers call `DomRegistry.parse(html)`: they never touch `DOMParser` directly. `DomRegistry.register()` still accepts a custom parser and takes full precedence. -- `HlsUtils.rewriteManifest` rewrites every URI line in an `.m3u8` (including `URI="…"` inside `#EXT-X-KEY` / `#EXT-X-MAP`) so chunk fetches go through the same proxy as the manifest fetch. +- `http.ts`: `HttpClient` wraps `fetch` with per-host rate limiting, exponential-backoff retry (honours `Retry-After`), curl fallback transport, and end-to-end `AbortSignal` composition. +- `dom.ts`: `DomRegistry` + `BrowserDomParser`. Auto-registers `linkedom` (a direct dependency) on first parse — no consumer shim needed. +- `hls.ts`: `HlsUtils.rewriteManifest` rewrites `.m3u8` URIs to route through a proxy. +- `id.ts`: `encodeId`/`decodeId` — base64url-JSON opaque IDs. All `Media`/`Episode`/`Chapter` ids are encoded here. Also exports legacy URN helpers (`buildUrn` etc.) for backward compat. +- `mapping.ts`: `MappingClient` — cross-source ID resolver. Four-step waterfall: cache → `source.lookupByMapping` → MALSync/Anify (raced) → fuzzy title match. Not exported. -### Unified URN ID space +**2. Types and errors (`src/types.ts`, `src/errors.ts`, `src/config.ts`)**: all public value types. -Every `id` flowing in or out of the SDK is a URN of shape `${providerId}:${rawId}`. The first colon is the separator; the raw portion is opaque and may itself contain colons or slashes. Helpers in `src/utils/urn.ts`: +- `Media`, `Episode`, `Chapter`, `Stream`, `Pages`, `List`, `SourceInfo`, `Score` — plain POJOs, `JSON.stringify`-safe. +- `AniError extends Error` with `AniErrorCode` const enum (`SourceUnavailable`, `NoStream`, `RegionBlocked`, `RateLimited`, `NotFound`, `Cancelled`, `BadId`). +- `SdkOptions` + `resolveOptions()`. -- `buildUrn(provider, raw)` / `parseUrn(urn)` / `unwrapUrn(provider, urn)` — the standard pair. -- `strictUnwrapUrn(provider, urn)` — throws on prefix mismatch. The server uses this on `/meta/info` to catch routing bugs at the boundary. -- `buildTypedUrn(provider, kind, raw)` / `parseTypedUrn(provider, urn)` — typed catalogue URNs (`mal:anime:21`, `kitsu:manga:13`). MAL and Kitsu integer IDs aren't globally unique across anime/manga, so they're encoded with the catalogue kind as the second segment. +**3. Sources (`src/sources/`)**: internal, not exported from `src/index.ts`. -Providers accept legacy bare IDs as input for backwards compatibility (via the non-strict `unwrapUrn`). Public surface always emits URN form. +Single `Source` interface (`src/sources/base.ts`) replaces the old `BaseProvider` + `BaseMetadataProvider` split. Capability flags: `search`, `info`, `episodes`, `chapters`, `stream`, `pages`, `browse`, `mapping`. -**2. Extractors (`src/extractors/`)**: stateless, take only an embed URL and an `HttpClient`, return `IVideoPayload[]` (empty if they can't recover a direct stream). `BaseExtractor` is the contract. They're independently usable: a consumer can hand any embed URL to `BloggerExtractor` without involving a provider. +- Catalogue sources: `anilist.ts`, `mal.ts`, `kitsu.ts` — implement `search`, `info`, `browse`. +- Anime playback: `allmanga.ts`, `megaplay.ts`, `animeparadise.ts`, `anikoto.ts`, `gogoanime.ts`, `goyabu.ts` — implement `episodes`, `stream`. +- Manga: `mangadex.ts`, `mangapill.ts`, `weebcentral.ts` — implement `chapters`, `pages`. -**3. Providers (`src/providers/`)**: site-specific. `BaseProvider` defines `search` → `fetchContentUnits(mediaId)` → `resolveStream(unitId, language?)`. `fetchContentUnits` is **language-agnostic** and returns one unified list; each `IContentUnit` carries `availableLanguages: ContentLanguage[]` so the caller picks the translation at `resolveStream` time. Providers may optionally implement `fetchUnitTracks(unitId, language?): Promise` to expose subtitle/quality metadata without paying the `resolveStream` cost. `IVideoPayload.subtitles?: ISubtitleTrack[]` carries playable VTT URLs alongside the stream. Each provider composes one or more extractors: +All sources use `encodeId`/`decodeId` for IDs. `stream(episodeId)` and `pages(chapterId)` decode the opaque ID to dispatch to the right source. -- `AnimeParadiseProvider`: `api.animeparadise.moe` REST. `/anime/{id}/episode` for the list (sub only). `/ep/{uid}?origin={animeId}` returns the playable HLS link **and** `subData`, which `normalizeSubtitleEntries` (in `utils/subtitles.ts`) turns into VTT-only `ISubtitleTrack[]`. Implements `fetchUnitTracks` cheaply (just `/ep`, no stream URL resolution). -- `AllmangaProvider`: AllAnime GraphQL → AES-CTR-decrypted `tobeparsed` payload → `Mp4UploadExtractor`, with a `clock.json` fallback for wixmp/sharepoint sources. Source URLs are obfuscated with a `--` scheme XOR'd with `0x38`; see `decodeAllAnimeSource`. `fetchContentUnits` merges `availableEpisodesDetail.sub` + `.dub` + `.raw` into a single language-agnostic list; unit IDs are `${mediaId}/${epStr}` (legacy `${mediaId}/${epStr}/${lang}` IDs still resolve). -- `AnikotoProvider`: HTML scrape of `anikototv.to`; uses `anikotoapi.site` for episodes, then delegates to MegaPlay embed for stream/subtitles. -- `GogoanimeProvider`: HTML scrape of `anineko.to`; vibeplayer embed → `master.m3u8` via `GenericHlsExtractor`. -- `GoyabuProvider`: pulls a Blogger token from `playersData`, calls Google `batchexecute` to recover the `googlevideo.com` URL via `BloggerExtractor`. -- `MangadexProvider`: Official JSON API at `api.mangadex.org` for high-quality manga. -- `MangapillProvider`: HTML scrape of `mangapill.com` for manga. -- `MegaPlayProvider`: AniList GraphQL for search/episodes; resolves directly against MegaPlay's mapping endpoints. -- `WeebcentralProvider`: HTML scrape of `weebcentral.com` for manga. +**4. Registry + SDK (`src/registry.ts`, `src/sdk.ts`, `src/progressive.ts`, `src/health.ts`)**: public API. -All public surface is re-exported from `src/index.ts`, including the shared subtitle utilities (`normalizeSubtitleEntries`, `proxifySubtitleUrl`). +- `Registry`: holds sources, implements `fanOutSearch` (returns `ProgressiveResult`), `mergeEpisodes`, `rankPlaybackSources`. +- `HealthTracker`: rolling 20-call success/latency window per source. Used to rank playback sources. +- `ProgressiveResult`: implements `AsyncIterable` (results as they arrive) and `PromiseLike` (collect all). `cancel()` aborts via `AbortSignal.any()`. +- `Sdk` class: 9 verbs — `search`, `info`, `sources`, `episodes`, `chapters`, `stream`, `pages`, `browse`, `health`. Each accepts value objects or opaque id strings. +- `createSdk(opts?)`: zero-config factory that instantiates `HttpClient` + all enabled sources + `Registry`. -**4. Metadata layer (`src/meta/`)**: provider-agnostic catalogue access. +**5. Server (`src/server/`)**: thin consumer of the SDK. -- `BaseMetadataProvider` is the abstract surface: `search`, `fetchMediaInfo`, `fetchContentUnits(urn, contentProvider)`, `resolveStream(urn, episodeNumber, contentProvider, language?)`, `fetchUnitTracks(...)`, `browse(kind, options)`. Episode selection is by metadata-level number, with `'auto'`/`'always'`/`'never'` absolute-episode rescue (walks PREQUEL relations to compute a season offset). -- Concrete providers: - - `AnilistMeta`: graphql.anilist.co. Surfaces full `IMediaMetadata` plus `relations`, `characters` (with voice actors), `staff`, `recommendations`, `externalLinks`, and `streamingEpisodes` (per-episode title/thumbnail). Implements `browse({trending, popular, seasonal, top})`. - - `MalMeta`: Jikan v4 (api.jikan.moe). Typed URNs (`mal:anime:21` / `mal:manga:13`). Populates `streamingEpisodes` with Jikan's `filler`/`recap` flags. Implements `browse({top, popular, seasonal})`. Surfaces `relations` from `/anime/{id}/full`. - - `KitsuMeta`: kitsu.io JSON:API. Typed URNs. Surfaces cross-source `mappings` (AniList/MAL/AniDB/TVDB) from Kitsu's relationship graph. -- `MappingClient` resolves a meta record onto a content provider's raw media ID via a four-step waterfall: SdkCache → `provider.lookupByMapping` → external mapping APIs (MALSync + Anify raced in parallel; arm-server enriches the cache for follow-up lookups) → fuzzy title search. The fuzzy matcher uses composite similarity (Sørensen–Dice + token Jaccard + prefix score) with year and catalogType discriminators and an optional episode-count cross-check for borderline matches. The metadata record is **never mutated**; results land in the `SdkCache` keyed by `mapping:${metaProvider}:${metaNativeId}:${contentProvider}`. -- Per-content-provider native lookup hooks: `BaseProvider.lookupByMapping?(mappings)` lets a provider short-circuit the resolver when its site indexes by AniList/MAL/Kitsu directly. `MegaPlayProvider` opts in (its `mediaId` _is_ the AniList ID). Providers can also declare `static malsyncSites: readonly string[]` (Mangadex, Mangapill, WeebCentral do). +- `routes.ts`: 9 routes that decode params → call SDK → JSON-serialize. +- `startServerV2({ port, sdk })`: new single-call server. `sdk` defaults to `createSdk()`. +- `cli.ts`: process entry for `npx anime-sdk`. Reads `PORT`, `SOURCES_DISABLED` env vars. +- Legacy `startServer({ providers, metaProviders, ... })`: old 1.x API, kept for backward compat. -**5. Server (`src/server/index.ts`)**: `startServer({ providers, metaProviders?, port, proxy, cache, auth, proxyBase?, proxySignSecret?, proxyAllowedHosts? })`. +### ID space -Routes: +Every `id` field on `Media`, `Episode`, `Chapter` is a base64url-encoded JSON token: -- `GET /search` / `/content` / `/stream` / `/tracks` — content-provider operations. -- `GET /meta/search` / `/meta/info` / `/meta/content` / `/meta/stream` / `/meta/tracks` / `/meta/browse` — metadata operations. -- `GET /download/video` / `/download/manga/page` / `/download/manga/chapter` (+ `/progress` SSE variants) — file downloads. -- `GET /proxy` (when `proxy: true`) — accepts `url`, `h` (base64-JSON headers), `ct` (Content-Type override), and `sig` (required when `proxySignSecret` is set — HMAC-SHA256 of `url` + optional `|h=`, hex). The proxy rewriter signs URLs it emits automatically. -- `GET /health` / `/openapi.json` — discovery. +```json +{ "v": 1, "t": "media"|"episode"|"chapter", "s": "sourceId", "r": "rawId", "m": {} } +``` -The proxy base URL is derived from each incoming request's `Host` header (and `X-Forwarded-Proto` when present) so the SDK works behind reverse proxies without configuration. Override with `proxyBase`. SSRF risk is mitigated by `proxyAllowedHosts: string[]` (suffix-matched against the target's hostname). +Consumers treat ids as opaque strings. The SDK decodes them internally to dispatch calls to the right source. -`cache?: SdkCache` is an optional `{get, set}` interface (sync or async) that memoizes provider calls by namespaced keys: `search::`, `content::`, `stream:::`, `tracks:::`, `meta:search::`, `meta:info::`, `meta:content:::`, `meta:stream:<...>`, `meta:tracks:<...>`, `meta:browse:<...>`, plus mapping keys `mapping:::`. +Legacy URN helpers (`buildUrn`, `parseUrn`, `unwrapUrn`, `strictUnwrapUrn`, `buildTypedUrn`, `parseTypedUrn`) are in `src/internal/id.ts` and exported from `src/index.ts` for backward compat. -`/tracks` returns **501** for providers without `fetchUnitTracks`. `/meta/browse` returns **501** when the meta provider doesn't implement the requested kind. The example `examples/server.mjs` wires a `new Map()` as the cache and registers all content providers plus `AnilistMeta`, `MalMeta`, `KitsuMeta` (each with a shared `MappingClient`). The example website (`examples/website/`) demonstrates every SDK feature — browse, meta search, full `IMediaMetadata` display (characters, staff, relations, recommendations, external links, streaming episodes), cross-provider episode resolution, and downloads. The example CLI (`examples/cli/`) is a React Ink TUI (`npm start` from `examples/cli/`) with browse, meta search, media info with tabs, provider selection, episode list, and stream resolution screens. +### Extractors (`src/extractors/`) + +Stateless, take an embed URL + `HttpClient`, return `IVideoPayload[]`. Used internally by sources. `BloggerExtractor`, `Mp4UploadExtractor`, `GenericHlsExtractor`, `VidstreamingExtractor`. ## ESM import convention @@ -83,27 +78,23 @@ The proxy base URL is derived from each incoming request's `Host` header (and `X ## Tests -- **Unit tests** (`tests/*.test.ts`) cover pure-logic modules: `HttpClient`, `HlsUtils`, `DomRegistry`, extractor parsing, language inference, URN helpers, similarity matcher, rate limiter, retry policy. -- **E2E tests** (`tests/e2e/*.test.ts`) are intentionally **not mocked**. Each searches a popular title, picks an episode, resolves the stream, and runs it through `captureStreamScreenshot`: which probes URLs with a Range GET (`Content-Type` + MP4 `ftyp` magic) to distinguish embed pages from raw video, fetches an HLS segment ~5s in, strips PNG-wrapped segments, and runs `ffmpeg` to extract a frame. Output lands in `scratch/screenshots/screenshot_.png` (gitignored). Assertion: the PNG is >1KB. Don't try to make these tests pass by mocking: the whole point is to catch upstream site changes. -- Each E2E test sets `vitest` `timeout: 90000`: these are slow and that's expected. -- `references/` (cloned source from `ani-cli`, `animdl`, `GoAnime`, `mov-cli`) is gitignored prior art for site-scraping logic; not part of the build or tests. +- **Unit tests** (`tests/*.test.ts`): cover pure-logic modules — `HttpClient`, `HlsUtils`, `DomRegistry`, extractor parsing, language inference, URN helpers + new `encodeId`/`decodeId`, similarity matcher, rate limiter, retry policy, `ProgressiveResult`, `Registry`, `Sdk` smoke test, types/errors/config. +- **E2E tests** (`tests/e2e/*.test.ts`): live, non-mocked. Each searches a popular title, picks an episode/chapter, resolves the stream/pages, and (for anime) runs `captureStreamScreenshot` to screenshot a real video frame. Assertion: the PNG is >1KB. The new source tests use `*Source` classes; a `streamToPayload()` helper converts `Stream` to `IVideoPayload` for the screenshot helper. ### Testing rules (do not negotiate) -These rules exist because the only useful tests are the ones that catch real regressions. - -- **Never mock network requests.** No `vi.spyOn(http, 'get').mockResolvedValue(...)`, no `nock`, no fake `Response`. If the test needs an HTTP server, spawn a real `http.createServer(...)` in `beforeAll` and tear it down in `afterAll`. -- **Never use fake/fixture data in place of a live call.** No frozen JSON fixtures that pretend to be AniList/MAL/Kitsu responses. If you want to test a parser, run the parser against the live API. -- **Never "gracefully skip" a test.** Patterns like `if (!reachable) return;`, `it.skipIf(...)`, or `if (!process.env.X) return;` are **forbidden** — they make a red test look green. If the upstream is unreachable from this network, the test must fail loudly. The fix is to either (a) make the upstream reachable, (b) pick a different upstream the runner can reach, or (c) delete the test. A skipped test is a lie. -- **Tests must be real and pass.** Those are the only two states a test is allowed to be in. "Skipped because environment" is not a state. -- **If a test depends on something flaky** (a slow site, a rate-limited API), make the test handle the flakiness via the SDK's own retry/timeout policy — not via skipping. -- **Stubs that replace `BaseProvider` are allowed only for testing pure SDK logic** (e.g. the meta provider's episode-picking algorithm) where the content provider's network behavior is genuinely orthogonal. Stubs of `HttpClient` or external HTTP responses are not. +- **Never mock network requests.** No `vi.spyOn(http, 'get').mockResolvedValue(...)`, no `nock`, no fake `Response`. +- **Never use fake/fixture data in place of a live call.** +- **Never "gracefully skip" a test.** `if (!reachable) return;`, `it.skipIf(...)` etc. are forbidden. +- **Tests must be real and pass.** Those are the only two states a test is allowed to be in. +- **Stubs that replace `BaseProvider` are allowed only for testing pure SDK logic** (e.g. the registry's source-ranking) where the content provider's network behavior is genuinely orthogonal. -## Provider/extractor additions +## Source/extractor additions -When adding a provider: +When adding a source: -- Extend `BaseProvider`, set `id` and `supportedTypes`, accept `HttpClient` in the constructor. -- Compose existing extractors where possible; only add a new extractor if the embed format is genuinely novel. -- Re-export from `src/index.ts`. -- Add a live E2E test that resolves a real stream and screenshots it. +- Implement the `Source` interface from `src/sources/base.ts`. +- Use `encodeId`/`decodeId` from `src/internal/id.ts` for all external IDs. +- Compose existing extractors where possible. +- Re-register in `buildSources()` in `src/sdk.ts` with the source's `id`. +- Add a live E2E test that resolves a real stream/pages and screenshots it. diff --git a/README.md b/README.md index ba2c6dd..3da3755 100644 --- a/README.md +++ b/README.md @@ -1,294 +1,153 @@ -# anime-sdk +# anime-sdk 2.0 -A Typescript SDK for searching anime and manga across multiple catalogue sources and content providers, normalizing them behind a single API, and resolving direct playable streams or page URLs (with subtitle tracks). +A TypeScript SDK for searching anime and manga, resolving playable streams and manga pages. Library-first — the SDK is the product, the bundled HTTP server is a convenience. [anime-sdk.hexxt.dev](https://animesdk.hexxt.dev/) -What's in the box: - -- **Nine content providers** (anime + manga) with live, non-mocked E2E tests. -- **Three metadata providers** — AniList, MAL (Jikan), Kitsu — with full - enrichments (relations, characters, staff, recommendations, external - links, per-episode `streamingEpisodes` with Jikan filler/recap flags). -- **Unified URN ID space** (`provider:rawId`) so you can swap a content - provider without rewriting your call sites. -- **Cross-source mapping** via a four-step waterfall (cache → provider - native lookup → MALSync/Anify/arm-server → fuzzy title match with year + - catalogType discriminators and episode-count cross-check). -- **A pluggable HTTP transport** (curl fallback included), with built-in - per-host rate limiting, exponential-backoff retry honouring `Retry-After`, - and end-to-end `AbortSignal` propagation. -- **Built-in downloads** for anime (HLS → MP4) and manga (chapters → ZIP). -- **An optional HTTP server** with content + metadata + download routes, - a header-forwarding `/proxy` (HMAC-signable + suffix-matched SSRF - allowlist) and a `GET /openapi.json` spec for client codegen. - -## Providers - -| ID | Site | Type | Languages | Subtitles | What it scrapes | -| --------------- | ------------------- | ----- | ----------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| `animeparadise` | `animeparadise.moe` | Anime | sub | yes | REST API at `api.animeparadise.moe`; episode carries a signed `streamLink` token; streamed as multi-quality HLS via `stream.animeparadise.moe`. | -| `allmanga` | `allmanga.to` | Anime | sub, dub | no | AllAnime GraphQL → AES-CTR `tobeparsed` payload → Mp4Upload extractor (with `clock.json` fallback for the wixmp/sharepoint sources). | -| `gogoanime` | `anineko.to` | Anime | sub | no | Page scraping; vibeplayer embed → `master.m3u8` via `GenericHlsExtractor` (sequential, stops on first success). | -| `goyabu` | `goyabu.io` | Anime | pt-br (dub) | no | Pulls the Blogger token from `playersData`, then calls Google's `batchexecute` endpoint to recover the `googlevideo.com` URL. | -| `mangadex` | `mangadex.org` | Manga | sub | no | Official JSON API at `api.mangadex.org` with cover art and high-quality page resolution. | -| `weebcentral` | `weebcentral.com` | Manga | sub | no | Page scraping; extracts high-quality images with referer protection. | -| `mangapill` | `mangapill.com` | Manga | sub | no | Page scraping; efficient extraction of chapter page lists and direct image sources. | -| `anikoto` | `anikototv.to` | Anime | sub, dub | yes | Page scraping; uses `anikotoapi.site` for episodes, and `megaplay.buzz` for stream and subtitle extraction. | -| `megaplay` | `megaplay.buzz` | Anime | sub, dub | yes | Uses AniList GraphQL for search and episodes, and resolves streams directly against MegaPlay's AniList mapping endpoints. | - -Every provider has a live E2E test that searches, picks an episode/chapter, resolves -the stream/pages, and captures a real video frame or verifies page links. - -## Metadata providers - -| ID | Catalogue | Native ID shape | Enrichments | -| --------- | --------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| `anilist` | AniList GraphQL | `anilist:21` | full metadata + relations + characters (with voice actors) + staff + recommendations + externalLinks + streamingEpisodes + browse | -| `mal` | MyAnimeList | `mal:anime:21` | filler/recap flags via Jikan episodes + relations + browse(top/popular/seasonal) | -| `kitsu` | Kitsu JSON:API | `kitsu:anime:1` | core metadata + cross-source mappings (AniList/MAL/AniDB/TVDB) | - -## Architecture +## Install -``` -src/ -├── transport/ -│ ├── http.ts HttpClient: rate-limit + retry + AbortSignal + pluggable transport -│ ├── transport.ts HttpTransport interface; CurlFallbackTransport (default), FetchTransport -│ ├── rateLimiter.ts Per-host token bucket with secondary burst window -│ ├── retry.ts withRetry + Retry-After parser -│ ├── dom.ts DOMParser registry (auto-registers linkedom in Node) -│ └── hlsUtils.ts Rewrite m3u8 chunk URLs through a proxy -├── extractors/ -│ ├── Mp4UploadExtractor Direct mp4 from www.mp4upload.com -│ ├── BloggerExtractor Google batchexecute → googlevideo URLs -│ ├── VidstreamingExtractor Legacy Gogo encrypt-ajax flow -│ └── GenericHlsExtractor Best-effort m3u8/mp4 scrape from an embed page -├── providers/ -│ ├── BaseProvider URN wrap/unwrap, concurrency cap, lookupByMapping hook, malsyncSites -│ ├── AllmangaProvider, AnikotoProvider, AnimeParadiseProvider, GogoanimeProvider, -│ ├── GoyabuProvider, MangadexProvider, MangapillProvider, MegaPlayProvider, WeebcentralProvider -├── meta/ -│ ├── BaseMetadataProvider Episode picking + IContentUnit enrichment + absolute-episode rescue -│ ├── AnilistMeta, MalMeta, KitsuMeta -│ ├── MappingClient cache → provider → MALSync/Anify/arm-server → fuzzy waterfall -│ └── similarity.ts normalizeTitle, dice + token Jaccard + prefix composite -├── download/ Built-in HLS → MP4 and chapter → ZIP download helpers -├── server/index.ts startServer: /search, /content, /stream, /tracks, /meta/*, /download/*, /proxy, /openapi.json, /health -├── types/index.ts CallOptions, IMediaSearchResult, IMediaMetadata, IContentUnit, … -└── utils/ - ├── crypto.ts AES-CBC + AES-CTR helpers - ├── subtitles.ts normalizeSubtitleEntries, proxifySubtitleUrl (with signSecret) - └── urn.ts buildUrn, unwrapUrn, strictUnwrapUrn, buildTypedUrn, parseTypedUrn +```bash +npm install anime-sdk ``` -### Unified URN IDs +## Usage -Every `id` flowing in or out of the SDK has shape `${providerId}:${rawId}`: +### Search → stream (3 calls) -``` -allmanga:5jzpRTJWnubrgHm5G # media URN -allmanga:5jzpRTJWnubrgHm5G/1 # content-unit URN -anilist:21 # meta URN (single ID namespace) -mal:anime:21 # typed catalogue URN (anime/manga distinction) -kitsu:manga:13 # typed catalogue URN -``` +```ts +import { createSdk } from 'anime-sdk'; -The first colon is the separator; raw IDs may themselves contain colons or -slashes. Use `strictUnwrapUrn` when a URN must belong to a specific -provider (the server enforces this on `/meta/info`). +const sdk = createSdk(); // zero config — all 12 sources enabled -A provider is just a class with `search`, `fetchContentUnits`, and -`resolveStream`. `fetchContentUnits` is language-agnostic — it returns one -unified list and each `IContentUnit` carries `availableLanguages` so the -caller can pick at `resolveStream` time. Providers may optionally implement -`fetchUnitTracks(unitId, language?)` to expose subtitle/quality metadata -cheaply (no full stream resolution). Extractors are stateless and take a -`HttpClient`, so you can mix and match (or use the extractors on their own). +// 1. Search +const results = await sdk.search('frieren', { kind: 'anime' }); +// or iterate as results arrive: +for await (const hit of sdk.search('frieren', { kind: 'anime' })) { + console.log(hit.title.preferred); +} -## Usage +// 2. List episodes +const { items: episodes } = await sdk.episodes(results[0]); +// items is Episode[] — each has an opaque .id -```ts -import { HttpClient, AllmangaProvider, MangadexProvider } from 'anime-sdk'; +// 3. Resolve stream +const stream = await sdk.stream(episodes[0], { language: 'sub' }); +console.log(stream.url); // playable HLS or MP4 URL +console.log(stream.origin.host); // origin hostname (no proxy URL parsing needed) +console.log(stream.adjacent.next); // prev/next episode for navigation +``` -const http = new HttpClient({ timeoutMs: 25_000 }); +### Manga -// Anime -const anime = new AllmangaProvider(http); -const shows = await anime.search('Frieren'); -const eps = await anime.fetchContentUnits(shows[0].id); -const stream = await anime.resolveStream(eps[0].id, 'sub'); +```ts +const { items: chapters } = await sdk.chapters(mangaResult); +const pages = await sdk.pages(chapters[0]); // no language argument +console.log(pages.pages.map((p) => p.url)); +``` -// Manga -const manga = new MangadexProvider(http); -const books = await manga.search('Frieren'); -const chapters = await manga.fetchContentUnits(books[0].id); -const pages = await manga.resolveStream(chapters[0].id); +### Browse -if (pages.type === 'manga') { - console.log(pages.pages.imageUrls); // Array of high-res page URLs -} +```ts +const trending = await sdk.browse({ list: 'trending', kind: 'anime' }); ``` -### Cross-source: metadata + content provider - -The metadata layer lets you swap content providers without changing -anything else. Mapping (AniList ID → AllManga raw ID) happens -automatically: +### HTTP server (one line) ```ts -import { - HttpClient, - AnilistMeta, - MappingClient, - AllmangaProvider, - GogoanimeProvider, -} from 'anime-sdk'; - -const http = new HttpClient(); -const mapping = new MappingClient(http); -const meta = new AnilistMeta(http, { mappingClient: mapping }); - -// Pull rich metadata from AniList (relations, characters, streamingEpisodes…) -const info = await meta.fetchMediaInfo('anilist:1'); -console.log(info.title.english, info.streamingEpisodes?.[0].title); - -// Resolve a stream on any content provider using the same AniList URN. -const allmanga = new AllmangaProvider(http); -const stream = await meta.resolveStream('anilist:1', 1, allmanga, 'sub'); - -// Or swap to a different content provider — no other changes. -const gogo = new GogoanimeProvider(http); -const stream2 = await meta.resolveStream('anilist:1', 1, gogo, 'sub'); +import { startServerV2 } from 'anime-sdk/server'; +await startServerV2({ port: 3030 }); // SDK auto-constructed from env ``` -### Browse the catalogue +Or via CLI: -```ts -const trending = await meta.browse('trending', { catalogType: 'ANIME', perPage: 10 }); -const seasonal = await meta.browse('seasonal', { season: 'FALL', year: 2024 }); -const top = await meta.browse('top'); +```sh +npx anime-sdk # → listening on http://localhost:3030 +PORT=8080 npx anime-sdk +SOURCES_DISABLED=goyabu npx anime-sdk ``` -### HTTP server with proxy + cache + metadata routes +### Custom config ```ts -import { - HttpClient, - startServer, - AllmangaProvider, - MangadexProvider, - AnilistMeta, - MalMeta, -} from 'anime-sdk'; - -const http = new HttpClient(); -const store = new Map(); -const cache = { - get: (key) => store.get(key), - set: (key, value) => void store.set(key, value), -}; - -startServer({ - providers: [new AllmangaProvider(http), new MangadexProvider(http)], - metaProviders: [new AnilistMeta(http), new MalMeta(http)], - port: 3000, - proxy: true, - proxySignSecret: process.env.PROXY_SECRET, // optional: signs /proxy URLs - proxyAllowedHosts: ['wixstatic.com', 'allanime.day'], // optional SSRF allowlist - cache, +createSdk({ + sources: ['anilist', 'allmanga'], // whitelist + disabled: ['goyabu'], // or blacklist + http: { timeoutMs: 10000, retries: 2 }, + proxy: { signSecret: process.env.PROXY_SECRET }, + cache: { + get: (k) => store.get(k), + set: (k, v) => store.set(k, v), + }, }); ``` -Routes the server exposes: - -| Route | Purpose | -| ----------------------------- | -------------------------------------------------------------------------------------- | -| `GET /search` | Search a content provider | -| `GET /content` | Episode/chapter list for a media URN | -| `GET /stream` | Resolve a playable stream for a unit URN | -| `GET /tracks` | Cheap subtitle/quality list (501 if provider doesn't support it) | -| `GET /meta/search` | Search a metadata catalogue | -| `GET /meta/info` | Full `IMediaMetadata` for a meta URN | -| `GET /meta/content` | Episode list for a meta URN, resolved via a content provider | -| `GET /meta/stream` | Resolve a stream by metadata + episode number | -| `GET /meta/tracks` | Cheap tracks for an episode (501 if provider doesn't support it) | -| `GET /meta/browse` | Trending / popular / seasonal / top | -| `GET /download/video` | Download an anime episode as MP4 | -| `GET /download/manga/page` | Download a single manga page | -| `GET /download/manga/chapter` | Download a manga chapter as a ZIP | -| `GET /proxy` | CORS-friendly upstream proxy (header forwarding, HLS rewrite, optional HMAC signature) | -| `GET /openapi.json` | OpenAPI 3.1 spec describing every route | -| `GET /health` | Health + capability check | - -### Direct extractor use - -Extractors work standalone — hand them an embed URL from any source and -they'll return a list of `IVideoPayload` (or an empty array if they can't -recover a direct stream). +### Error handling ```ts -import { HttpClient, BloggerExtractor } from 'anime-sdk'; - -const blogger = new BloggerExtractor(new HttpClient()); -const streams = await blogger.extract('https://www.blogger.com/video.g?token=AD6v5dw…'); +import { AniError, AniErrorCode } from 'anime-sdk'; + +try { + const stream = await sdk.stream(episode); +} catch (e) { + if (e instanceof AniError) { + switch (e.code) { + case AniErrorCode.NoStream: // no playable URL found + case AniErrorCode.SourceUnavailable: // upstream unreachable + case AniErrorCode.RateLimited: // rate limit hit + case AniErrorCode.Cancelled: // AbortSignal fired + case AniErrorCode.NotFound: // title/episode not found + } + } +} ``` ### Cancellation -Every public method takes a `CallOptions` bag with an optional -`AbortSignal`. It's threaded all the way down to `fetch`, the rate -limiter (so a long queue can be drained on abort), and the retry loop. - ```ts const ac = new AbortController(); -setTimeout(() => ac.abort(), 1500); -const results = await meta.search('frieren', { signal: ac.signal }); +setTimeout(() => ac.abort(), 5000); +const results = await sdk.search('frieren', { kind: 'anime', signal: ac.signal }); ``` -## Tests - -```bash -# Everything (unit + live e2e, ~60s total) -npx vitest run +## Sources + +| ID | Site | Type | Capabilities | +| --------------- | ------------------ | ----------- | -------------------- | +| `anilist` | graphql.anilist.co | anime+manga | search, info, browse | +| `mal` | api.jikan.moe | anime+manga | search, info, browse | +| `kitsu` | kitsu.io | anime+manga | search, info | +| `allmanga` | allmanga.to | anime | episodes, stream | +| `megaplay` | megaplay.buzz | anime | episodes, stream | +| `animeparadise` | animeparadise.moe | anime | episodes, stream | +| `anikoto` | anikototv.to | anime | episodes, stream | +| `gogoanime` | anineko.to | anime | episodes, stream | +| `goyabu` | goyabu.io | anime | episodes, stream | +| `mangadex` | mangadex.org | manga | chapters, pages | +| `mangapill` | mangapill.com | manga | chapters, pages | +| `weebcentral` | weebcentral.com | manga | chapters, pages | + +## Server routes (v2) -# Just the live providers -npx vitest run tests/e2e +``` +GET /search?q=…&kind=anime → Media[] +GET /media/:id → Media +GET /media/:id/episodes → List +GET /media/:id/chapters → List +GET /media/:id/sources → SourceInfo[] +GET /episode/:id/stream?language=sub → Stream +GET /chapter/:id/pages → Pages +GET /browse?list=trending&kind=anime → List +GET /health → SourceHealth[] ``` -The E2E suite is intentionally not mocked. Each test: - -1. Searches a popular title (`Frieren` for AllManga/Gogoanime, `Naruto` - Clássico for Goyabu). -2. Picks a mainline entry, fetches episodes, resolves a stream. -3. Walks the candidate list via `captureStreamScreenshot`, which: - -- probes a URL with a Range GET (Content-Type + MP4 `ftyp` magic) to tell - embed pages from direct video bytes, -- scrapes embed HTML for an `.m3u8`/`.mp4` URL when needed, -- downloads an HLS segment ~5s in and runs ffmpeg locally on it - (PNG-wrapped segments are stripped before decoding), or -- hands plain MP4 URLs straight to ffmpeg with `-user_agent`/`-referer`, +## API reference -4. Asserts the resulting PNG is >1KB before passing. +Public exports: `createSdk`, `Sdk`, `AniError`, `AniErrorCode`, `Media`, `Episode`, `Chapter`, `Stream`, `Pages`, `List`, `SourceInfo`, `SdkOptions`, `Score`. -Screenshots land in `scratch/screenshots/screenshot_.png`. -`scratch/` is gitignored. +Server: `startServerV2`, `ServerV2Options` (from `anime-sdk/server`). -The tests are **all real**. See `CLAUDE.md` for the non-negotiable testing -rules — short version: no mocked network requests, no fake/fixture data, -no graceful skipping. A test must pass for real or be deleted. +All types are plain POJOs — `JSON.stringify` round-trips, safe for React state, Zustand, Redux. ## Requirements -- Node 20+ (uses `fetch`, `globalThis.crypto.subtle`, top-level await in - tests). -- `ffmpeg` on `PATH` for the E2E suite. - -## License - -MIT - -## DMCA - -anime-sdk does not host, store, or distribute any media content. It resolves publicly accessible URLs served by third-party sites. For copyright concerns about content on those sites, contact them directly. To report infringement in the SDK code or this repository, open an issue tagged `legal`. +Node 20+. `ffmpeg` on `PATH` (E2E test suite only). From 329c60764a228ee9decb96b8b160b1689f4203ce Mon Sep 17 00:00:00 2001 From: HEXXT Date: Thu, 18 Jun 2026 17:34:03 +0100 Subject: [PATCH 14/19] fix(registry): wire resolveMediaId() for cross-source episode lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry's mergeEpisodes() was only checking media.mappings.sources?.[src.id] which is never populated when media comes from a catalogue source (AniList, MAL, Kitsu) — it has no AllManga/MegaPlay source ID baked in. Add resolveMediaId() to Registry: checks cached mappings.sources first, then calls source.lookupByMapping() if the source has the mapping capability. Caches the result in media.mappings.sources for subsequent calls. Add mergeChapters() to Registry (parallel to mergeEpisodes) and use it in Sdk. Fix AllmangaSource.lookupByMapping: was incorrectly searching by AniList numeric ID as text — AllManga has no native AniList lookup, return null. Fix MangadexSource.lookupByMapping: was using ids[] with a MAL ID — MangaDex ids[] expects UUIDs, not MAL IDs, return null. MegaPlaySource.lookupByMapping correctly returns the AniList ID (MegaPlay indexes by AniList ID natively) — this one works. sdk.episodes(anilistMedia) now works for MegaPlay; AllManga cross-source resolution still requires a title-based MALSync lookup (out of scope for Phase 6 — existing MappingClient in src/internal/mapping.ts handles this for the legacy server but isn't wired to the new Source interface yet). --- src/registry.ts | 72 ++++++++++++++++++++++++++++++++++++----- src/sdk.ts | 7 +--- src/sources/allmanga.ts | 10 +++--- src/sources/mangadex.ts | 17 +++------- 4 files changed, 74 insertions(+), 32 deletions(-) diff --git a/src/registry.ts b/src/registry.ts index aa0b521..b1134f7 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -1,5 +1,5 @@ import type { Source, SourceCallOpts } from './sources/base.js'; -import type { Media, Episode, List, SourceInfo } from './types.js'; +import type { Media, Episode, Chapter, List, SourceInfo } from './types.js'; import { HealthTracker } from './health.js'; import { createProgressiveResult, type ProgressiveResult } from './progressive.js'; @@ -49,7 +49,7 @@ export class Registry { const ranked = this.rankByHealth(sources); for (const src of ranked) { - const mediaId = media.mappings.sources?.[src.id]; + const mediaId = await this.resolveMediaId(media, src, opts); if (!mediaId) continue; const t0 = Date.now(); try { @@ -63,24 +63,80 @@ export class Registry { return { items: [] }; } + async mergeChapters( + media: Media, + opts: SourceCallOpts & { cursor?: string; limit?: number }, + ): Promise> { + const kind = media.kind; + const sources = this.sourcesFor(kind, 'chapters'); + if (sources.length === 0) return { items: [] }; + + const ranked = this.rankByHealth(sources); + for (const src of ranked) { + const mediaId = await this.resolveMediaId(media, src, opts); + if (!mediaId) continue; + const t0 = Date.now(); + try { + const result = await src.chapters!(mediaId, opts); + this.health.record(src.id, true, Date.now() - t0); + return result; + } catch { + this.health.record(src.id, false, Date.now() - t0); + } + } + return { items: [] }; + } + async rankPlaybackSources(media: Media, opts: SourceCallOpts): Promise { const kind = media.kind; - const sources = this.sourcesFor(kind, 'episodes'); - return sources.map((src) => { + const sources = this.sourcesFor(kind, 'episodes').concat(this.sourcesFor(kind, 'chapters')); + const results: SourceInfo[] = []; + for (const src of sources) { const h = this.health.get(src.id); - const mediaId = media.mappings.sources?.[src.id]; - return { + const mediaId = await this.resolveMediaId(media, src, opts).catch(() => null); + results.push({ id: src.id, status: mediaId ? 'available' : 'incompatible', successRate: h.successRate, - } satisfies SourceInfo; - }); + } satisfies SourceInfo); + } + return results; } getHealthTracker(): HealthTracker { return this.health; } + /** + * Resolve the playback source's native media ID for a given Media record. + * Checks cached mappings.sources first, then calls source.lookupByMapping() + * if the source declares the mapping capability. + */ + async resolveMediaId(media: Media, src: Source, opts: SourceCallOpts): Promise { + // 1. Cached in the media record + const cached = media.mappings.sources?.[src.id]; + if (cached) return cached; + + // 2. Source-native lookup via cross-source mappings (AniList ID, MAL ID, etc.) + if (src.caps.mapping && src.lookupByMapping) { + try { + const resolved = await src.lookupByMapping(media.mappings as Record, { + signal: opts.signal, + }); + if (resolved) { + // Cache in-place so subsequent calls skip this lookup + if (!media.mappings.sources) media.mappings.sources = {}; + media.mappings.sources[src.id] = resolved; + return resolved; + } + } catch { + // fall through — source lookup failed + } + } + + return null; + } + private rankByHealth(sources: Source[]): Source[] { return [...sources].sort((a, b) => { const ha = this.health.get(a.id); diff --git a/src/sdk.ts b/src/sdk.ts index 98b428f..7b232dd 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -117,12 +117,7 @@ export class Sdk { opts?: { signal?: AbortSignal; cursor?: string; limit?: number }, ): Promise> { const m = typeof media === 'string' ? await this.info(media, opts) : media; - const kind = m.kind; - const sources = this.registry.sourcesFor(kind, 'chapters'); - if (sources.length === 0) return { items: [] }; - const mediaId = m.mappings.sources?.[sources[0].id]; - if (!mediaId) return { items: [] }; - return sources[0].chapters!(mediaId, { + return this.registry.mergeChapters(m, { signal: opts?.signal, cursor: opts?.cursor, limit: opts?.limit, diff --git a/src/sources/allmanga.ts b/src/sources/allmanga.ts index f6878a8..aed065c 100644 --- a/src/sources/allmanga.ts +++ b/src/sources/allmanga.ts @@ -208,14 +208,12 @@ export class AllmangaSource implements Source { } async lookupByMapping( - mappings: Record, + _mappings: Record, _opts?: SourceCallOpts, ): Promise { - const m = mappings as IMediaMappings; - if (m.anilist) { - const results = await this.search(String(m.anilist), 'anime', {}); - return results[0]?.mappings.sources?.['allmanga'] ?? null; - } + // AllManga doesn't index by AniList/MAL ID natively. + // Cross-source resolution requires a title search (done externally + // by the registry when the caller provides a title via search results). return null; } diff --git a/src/sources/mangadex.ts b/src/sources/mangadex.ts index e3c8cbf..720aa5f 100644 --- a/src/sources/mangadex.ts +++ b/src/sources/mangadex.ts @@ -82,18 +82,11 @@ export class MangadexSource implements Source { } async lookupByMapping( - mappings: Record, - opts?: SourceCallOpts, + _mappings: Record, + _opts?: SourceCallOpts, ): Promise { - const mal = mappings.mal; - if (!mal) return null; - const url = `${MANGADEX_API}/manga?ids[]=${String(mal)}&contentRating[]=safe`; - try { - const res = await this.http.get(url, { signal: opts?.signal }); - const data = (await res.json()) as any; - return data.data?.[0]?.id ?? null; - } catch { - return null; - } + // MangaDex doesn't have a native MAL/AniList ID lookup endpoint. + // Cross-source resolution requires a title search via the search() method. + return null; } } From 9647bd4ded18c47e7ba375657885fe2fbcca3719 Mon Sep 17 00:00:00 2001 From: HEXXT Date: Fri, 19 Jun 2026 06:17:00 +0100 Subject: [PATCH 15/19] feat(content): update marketing website, CLI, and Ink TUI to 2.0 API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marketing website (Astro): - Demo.astro: createSdk() + sdk.search/episodes/stream instead of MegaPlayProvider - Server.astro: npx anime-sdk + startServerV2() instead of startServer({ providers }) - Proxy.astro: createSdk({ proxy }) instead of startServer({ providers, proxy }) - Contribute.astro: Source interface + createSdk() instead of BaseProvider - CodeEditorScene.tsx: createSdk() typing animation instead of AllmangaProvider - TerminalScene.tsx: sdk.episodes/stream instead of fetchContentUnits/resolveStream - ProviderDashboardScene.tsx: 'allmanga' + stream.url instead of AllmangaProvider/resolveStream - faq.astro: sdk.stream(), sources/disabled config instead of provider-specific guidance - og/[...slug].png.ts: updated type names and source count (12 not 9) examples/cli.mjs: rewrite to createSdk(); covers anime+manga, uses sdk.search/episodes/chapters/stream/pages; prints stream.url, origin.host, adjacent. examples/cli/index.tsx: full Ink TUI rewrite (1147→370 lines). Uses createSdk() directly — no HTTP, no separate meta/content providers, no provider selection screen. Screens: home → browse/search → results → media → episodes/chapters → stream/pages. Episode ids are opaque (no display-label parsing). --- examples/cli.mjs | 98 +- examples/cli/index.tsx | 1391 +++++------------ .../components/islands/CodeEditorScene.tsx | 20 +- .../islands/ProviderDashboardScene.tsx | 6 +- .../src/components/islands/TerminalScene.tsx | 22 +- .../src/components/sections/Contribute.astro | 8 +- website/src/components/sections/Demo.astro | 36 +- website/src/components/sections/Proxy.astro | 47 +- website/src/components/sections/Server.astro | 53 +- website/src/pages/faq.astro | 16 +- website/src/pages/og/[...slug].png.ts | 6 +- 11 files changed, 530 insertions(+), 1173 deletions(-) diff --git a/examples/cli.mjs b/examples/cli.mjs index 91f6716..4df8ff3 100644 --- a/examples/cli.mjs +++ b/examples/cli.mjs @@ -1,15 +1,9 @@ import { createInterface } from 'node:readline/promises'; -import { stdin, stdout } from 'node:process'; -import { HttpClient, GogoanimeProvider, GoyabuProvider, AllmangaProvider } from '../dist/index.js'; +import { stdin } from 'node:process'; +import { createSdk } from '../dist/index.js'; -const io = createInterface({ input: stdin, output: stdout }); - -const http = new HttpClient({ timeoutMs: 30000 }); -const PROVIDERS = [ - new GogoanimeProvider(http), - new GoyabuProvider(http), - new AllmangaProvider(http), -]; +const io = createInterface({ input: stdin, output: process.stdout }); +const sdk = createSdk(); async function pick(items, label) { items.forEach((item, i) => console.log(` ${i + 1}. ${item}`)); @@ -19,16 +13,11 @@ async function pick(items, label) { console.log('\n═══ anime-sdk CLI ═══\n'); -const pi = await pick( - PROVIDERS.map((p) => p.id), - 'Provider', -); -const provider = PROVIDERS[pi]; - -const query = await io.question('\nSearch: '); +const kind = (await io.question('Kind (anime/manga) [anime]: ')).trim() || 'anime'; +const query = await io.question('Search: '); process.stdout.write('...\n'); -const results = await provider.search(query); +const results = await sdk.search(query, { kind }); if (!results.length) { console.log('No results.'); process.exit(0); @@ -36,41 +25,62 @@ if (!results.length) { console.log(''); const ri = await pick( - results.map((r) => `${r.title} [${r.catalogType}]`), + results.map((r) => `${r.title.preferred} [${r.kind}]`), 'Select title', ); const media = results[ri]; process.stdout.write('...\n'); -const units = await provider.fetchContentUnits(media.id); -if (!units.length) { - console.log('No episodes.'); - process.exit(0); -} -console.log(''); -const ui = await pick( - units.map((u) => `EP.${String(u.number).padStart(3, '0')} ${u.title} (${u.language})`), - 'Select episode', -); -const unit = units[ui]; +if (kind === 'manga') { + const { items: chapters } = await sdk.chapters(media); + if (!chapters.length) { + console.log('No chapters.'); + process.exit(0); + } -process.stdout.write('...\n'); -const stream = await provider.resolveStream(unit.id); + console.log(''); + const ci = await pick( + chapters.map((c) => `Ch.${String(c.number).padStart(3, '0')} ${c.title ?? ''}`), + 'Select chapter', + ); + const chapter = chapters[ci]; + process.stdout.write('...\n'); + const pages = await sdk.pages(chapter); + console.log(`\n─── PAGES (${pages.pages.length}) ───`); + pages.pages.slice(0, 3).forEach((p) => console.log(p.url)); + if (pages.pages.length > 3) console.log(`... (${pages.pages.length - 3} more)`); +} else { + const { items: episodes } = await sdk.episodes(media); + if (!episodes.length) { + console.log('No episodes.'); + process.exit(0); + } + + console.log(''); + const ei = await pick( + episodes.map( + (e) => + `EP.${String(e.number).padStart(3, '0')} ${e.title ?? ''} (${e.languages.join('/')})`, + ), + 'Select episode', + ); + const episode = episodes[ei]; + const lang = episode.languages[0] ?? 'sub'; + process.stdout.write('...\n'); + const stream = await sdk.stream(episode, { language: lang }); -console.log('\n─── STREAM ───'); -if (stream.type === 'video') { - for (const s of stream.streams) { - console.log( - `\n[${s.isHLS ? 'HLS' : 'MP4'}] ${s.quality}${s.language ? ' ' + s.language : ''}`, - ); - console.log(s.sourceUrl); - if (s.headers && Object.keys(s.headers).length) - console.log('headers:', JSON.stringify(s.headers)); + console.log('\n─── STREAM ───'); + console.log( + `[${stream.isHls ? 'HLS' : 'MP4'}] ${stream.language} origin: ${stream.origin.host}`, + ); + console.log(stream.url); + if (stream.qualities.length > 1) { + console.log('qualities:', stream.qualities.map((q) => q.label).join(', ')); + } + if (stream.adjacent.next) { + console.log(`next: EP.${stream.adjacent.next.number}`); } -} else if (stream.type === 'manga') { - console.log(`${stream.pages.imageUrls.length} pages`); - stream.pages.imageUrls.slice(0, 3).forEach((u) => console.log(u)); } io.close(); diff --git a/examples/cli/index.tsx b/examples/cli/index.tsx index 5e37ebc..41e32db 100644 --- a/examples/cli/index.tsx +++ b/examples/cli/index.tsx @@ -2,95 +2,44 @@ import React, { useState, useEffect, useCallback } from 'react'; import { render, Box, Text, useInput, useApp } from 'ink'; import TextInput from 'ink-text-input'; import { - HttpClient, - AllmangaProvider, - AnikotoProvider, - AnimeParadiseProvider, - GogoanimeProvider, - MegaPlayProvider, - MangadexProvider, - WeebcentralProvider, - AnilistMeta, - MalMeta, - MappingClient, - type IMetaSearchResult, - type IMediaMetadata, - type IContentUnit, - type ResolvedMediaStream, - type IVideoPayload, + createSdk, + type Media, + type Episode, + type Chapter, + type Stream, + type Pages, } from '../../dist/index.js'; -// ─── SDK setup ──────────────────────────────────────────────────────────────── +// ─── SDK ───────────────────────────────────────────────────────────────────── -const http = new HttpClient({ timeoutMs: 30_000 }); -const mapping = new MappingClient(http); - -const META_PROVIDERS = { - anilist: new AnilistMeta(http, { mappingClient: mapping }), - mal: new MalMeta(http, { mappingClient: mapping }), -} as const; -type MetaProviderId = keyof typeof META_PROVIDERS; - -const CONTENT_PROVIDERS = { - allmanga: new AllmangaProvider(http), - anikoto: new AnikotoProvider(http), - animeparadise: new AnimeParadiseProvider(http), - gogoanime: new GogoanimeProvider(http), - megaplay: new MegaPlayProvider(http), - mangadex: new MangadexProvider(http), - weebcentral: new WeebcentralProvider(http), -} as const; -type ContentProviderId = keyof typeof CONTENT_PROVIDERS; - -const CONTENT_PROVIDER_IDS = Object.keys(CONTENT_PROVIDERS) as ContentProviderId[]; -const META_PROVIDER_IDS = Object.keys(META_PROVIDERS) as MetaProviderId[]; -const BROWSE_KINDS = ['trending', 'popular', 'seasonal', 'top'] as const; -type BrowseKind = (typeof BROWSE_KINDS)[number]; +const sdk = createSdk({ http: { timeoutMs: 30_000 } }); // ─── Screen state ───────────────────────────────────────────────────────────── type Screen = | { type: 'home' } + | { type: 'browse'; loading: boolean; items: Media[]; error: string | null } + | { type: 'search' } + | { type: 'results'; items: Media[]; query: string } + | { type: 'media'; media: Media } + | { type: 'episodes'; media: Media; items: Episode[]; loading: boolean; error: string | null } + | { type: 'chapters'; media: Media; items: Chapter[]; loading: boolean; error: string | null } | { - type: 'browse'; - kind: BrowseKind; - metaProvider: MetaProviderId; - loading: boolean; - items: IMetaSearchResult[]; - error: string | null; - } - | { type: 'search'; metaProvider: MetaProviderId } - | { type: 'results'; items: IMetaSearchResult[]; query: string; metaProvider: MetaProviderId } - | { - type: 'media'; - info: IMediaMetadata; - tab: 'overview' | 'chars' | 'staff' | 'rels'; - metaProvider: MetaProviderId; - } - | { type: 'provider-select'; info: IMediaMetadata; metaProvider: MetaProviderId } - | { - type: 'episodes'; - info: IMediaMetadata; - contentProvider: ContentProviderId; - units: IContentUnit[]; + type: 'stream'; + episode: Episode; + result: Stream | null; loading: boolean; error: string | null; } | { - type: 'stream'; - info: IMediaMetadata; - unit: IContentUnit; - contentProvider: ContentProviderId; - result: ResolvedMediaStream | null; + type: 'pages'; + chapter: Chapter; + result: Pages | null; loading: boolean; error: string | null; }; -// ─── Shared helpers ─────────────────────────────────────────────────────────── - -function preferredTitle(t: IMediaMetadata['title'] | IMetaSearchResult['title']): string { - return t.english ?? t.romaji ?? t.userPreferred ?? t.native ?? '(untitled)'; -} +// ─── Helpers ───────────────────────────────────────────────────────────────── function stripHtml(html: string): string { return html @@ -109,1039 +58,431 @@ function truncate(s: string, n: number): string { // ─── Scrollable select list ─────────────────────────────────────────────────── -interface SelectListProps { +function SelectList({ + items, + active, + renderItem, + maxVisible = 12, +}: { items: T[]; active: number; renderItem: (item: T, isActive: boolean, index: number) => React.ReactNode; maxVisible?: number; -} - -function SelectList({ items, active, renderItem, maxVisible = 10 }: SelectListProps) { +}) { const start = Math.max(0, active - maxVisible + 3); const visible = items.slice(start, start + maxVisible); return ( {visible.map((item, i) => ( - {renderItem(item, start + i === active, start + i)} + {renderItem(item, start + i === active, start + i)} ))} + {items.length > maxVisible && ( + {` +${items.length - maxVisible} more`} + )} ); } -// ─── Divider ───────────────────────────────────────────────────────────────── - -function Divider({ label }: { label?: string }) { - const line = '─'.repeat(label ? 2 : 50); - return ( - - - {label ? `─── ${label} ${'─'.repeat(Math.max(0, 46 - label.length))}` : '─'.repeat(50)} - - - ); -} - -// ─── Status bar ────────────────────────────────────────────────────────────── - -function StatusBar({ hints }: { hints: string }) { - return ( - - - {hints} - - - ); -} - -// ─── Home screen ───────────────────────────────────────────────────────────── - -const HOME_ITEMS = [ - { label: 'Browse Trending', action: 'browse-trending' }, - { label: 'Browse Popular', action: 'browse-popular' }, - { label: 'Browse Seasonal', action: 'browse-seasonal' }, - { label: 'Search (meta)', action: 'search' }, - { label: 'Quit', action: 'quit' }, -] as const; +// ─── App ───────────────────────────────────────────────────────────────────── -function HomeScreen({ onSelect }: { onSelect: (action: string) => void }) { - const [active, setActive] = useState(0); +function App() { const { exit } = useApp(); + const [screen, setScreen] = useState({ type: 'home' }); + const [kind, setKind] = useState<'anime' | 'manga'>('anime'); + const [activeIdx, setActiveIdx] = useState(0); + const [searchInput, setSearchInput] = useState(''); + const [langIdx, setLangIdx] = useState(0); + + const push = useCallback((s: Screen) => { + setScreen(s); + setActiveIdx(0); + setLangIdx(0); + }, []); + + // ─── Browse loader ────────────────────────────────────────────────────────── + useEffect(() => { + if (screen.type !== 'browse' || !screen.loading) return; + sdk + .browse({ list: 'trending', kind }) + .then((list) => push({ type: 'browse', loading: false, items: list.items, error: null })) + .catch((e) => + push({ type: 'browse', loading: false, items: [], error: (e as Error).message }), + ); + }, [screen.type === 'browse' && screen.loading, kind]); - useInput((input, key) => { - if (key.upArrow) setActive((i) => Math.max(0, i - 1)); - if (key.downArrow) setActive((i) => Math.min(HOME_ITEMS.length - 1, i + 1)); - if (key.return) { - const item = HOME_ITEMS[active]; - if (item.action === 'quit') exit(); - else onSelect(item.action); - } - if (input === 'q') exit(); - }); - - return ( - - - - anime-sdk - - — React Ink TUI - - - - {HOME_ITEMS.map((item, i) => ( - - - {i === active ? '► ' : ' '} - {item.label} - - - ))} - - - - - ); -} + // ─── Episodes loader ──────────────────────────────────────────────────────── + useEffect(() => { + if (screen.type !== 'episodes' || !screen.loading) return; + const m = screen.media; + sdk + .episodes(m) + .then((list) => + setScreen({ type: 'episodes', media: m, items: list.items, loading: false, error: null }), + ) + .catch((e) => + setScreen({ + type: 'episodes', + media: m, + items: [], + loading: false, + error: (e as Error).message, + }), + ); + }, [screen.type === 'episodes' && screen.loading]); -// ─── Browse screen ──────────────────────────────────────────────────────────── + // ─── Chapters loader ──────────────────────────────────────────────────────── + useEffect(() => { + if (screen.type !== 'chapters' || !screen.loading) return; + const m = screen.media; + sdk + .chapters(m) + .then((list) => + setScreen({ type: 'chapters', media: m, items: list.items, loading: false, error: null }), + ) + .catch((e) => + setScreen({ + type: 'chapters', + media: m, + items: [], + loading: false, + error: (e as Error).message, + }), + ); + }, [screen.type === 'chapters' && screen.loading]); -function BrowseScreen({ - kind, - metaProvider, - onSelect, - onBack, -}: { - kind: BrowseKind; - metaProvider: MetaProviderId; - onSelect: (item: IMetaSearchResult) => void; - onBack: () => void; -}) { - const [active, setActive] = useState(0); - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + // ─── Stream loader ────────────────────────────────────────────────────────── + useEffect(() => { + if (screen.type !== 'stream' || !screen.loading) return; + const ep = screen.episode; + const lang = ep.languages[langIdx] ?? 'sub'; + sdk + .stream(ep, { language: lang }) + .then((result) => + setScreen({ type: 'stream', episode: ep, result, loading: false, error: null }), + ) + .catch((e) => + setScreen({ + type: 'stream', + episode: ep, + result: null, + loading: false, + error: (e as Error).message, + }), + ); + }, [screen.type === 'stream' && screen.loading]); + // ─── Pages loader ────────────────────────────────────────────────────────── useEffect(() => { - setLoading(true); - setError(null); - const provider = META_PROVIDERS[metaProvider]; - provider - .browse(kind, { catalogType: 'ANIME', perPage: 20 }) - .then(setItems) - .catch((e: Error) => setError(e.message)) - .finally(() => setLoading(false)); - }, [kind, metaProvider]); + if (screen.type !== 'pages' || !screen.loading) return; + const ch = screen.chapter; + sdk + .pages(ch) + .then((result) => + setScreen({ type: 'pages', chapter: ch, result, loading: false, error: null }), + ) + .catch((e) => + setScreen({ + type: 'pages', + chapter: ch, + result: null, + loading: false, + error: (e as Error).message, + }), + ); + }, [screen.type === 'pages' && screen.loading]); + // ─── Input handling ──────────────────────────────────────────────────────── useInput((input, key) => { - if (loading) return; - if (key.upArrow) setActive((i) => Math.max(0, i - 1)); - if (key.downArrow) setActive((i) => Math.min(items.length - 1, i + 1)); - if (key.return && items[active]) onSelect(items[active]); - if (key.escape || input === 'q') onBack(); - }); - - return ( - - - Browse / - - {kind} - - [{metaProvider}] - - - {loading && ( - - loading... - - )} - {error && ( - - {error} - - )} - {!loading && !error && ( - - { - const title = preferredTitle(item.title); - const score = item.score != null ? ` ★${(item.score / 10).toFixed(1)}` : ''; - const meta = [item.format, item.year].filter(Boolean).join(' '); - return ( - - - {isActive ? '► ' : ' '} - {truncate(title, 38)} - - {score && {score}} - {meta && ( - - {' '} - {meta} - - )} - - ); - }} - /> - - )} - - - - ); -} - -// ─── Search screen ──────────────────────────────────────────────────────────── + if (key.ctrl && input === 'c') exit(); -function SearchScreen({ - metaProvider, - onResults, - onBack, -}: { - metaProvider: MetaProviderId; - onResults: (items: IMetaSearchResult[], query: string) => void; - onBack: () => void; -}) { - const [query, setQuery] = useState(''); - const [searching, setSearching] = useState(false); - const [error, setError] = useState(null); + if (screen.type === 'home') { + if (input === 's') push({ type: 'search' }); + if (input === 'b') push({ type: 'browse', loading: true, items: [], error: null }); + if (input === 'a') setKind('anime'); + if (input === 'm') setKind('manga'); + } - const doSearch = useCallback(async () => { - if (!query.trim()) return; - setSearching(true); - setError(null); - try { - const results = await META_PROVIDERS[metaProvider].search(query.trim()); - onResults(results, query.trim()); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setSearching(false); + if (screen.type === 'browse' || screen.type === 'results') { + const items = screen.type === 'browse' ? screen.items : screen.items; + if (key.upArrow) setActiveIdx((i) => Math.max(0, i - 1)); + if (key.downArrow) setActiveIdx((i) => Math.min(items.length - 1, i + 1)); + if (key.return && items[activeIdx]) push({ type: 'media', media: items[activeIdx] }); + if (key.escape) push({ type: 'home' }); } - }, [query, metaProvider, onResults]); - useInput((input, key) => { - if (key.escape) onBack(); - }); + if (screen.type === 'media') { + const m = screen.media; + if (input === 'e' || input === 'r') { + if (m.kind === 'manga') + push({ type: 'chapters', media: m, items: [], loading: true, error: null }); + else push({ type: 'episodes', media: m, items: [], loading: true, error: null }); + } + if (key.escape) push({ type: 'home' }); + } - return ( - - - Search [ - - {metaProvider} - - ] - - - - - - - {searching && searching...} - {error && {error}} - - - - ); -} + if (screen.type === 'episodes') { + if (key.upArrow) setActiveIdx((i) => Math.max(0, i - 1)); + if (key.downArrow) setActiveIdx((i) => Math.min(screen.items.length - 1, i + 1)); + if (key.return && screen.items[activeIdx]) { + push({ + type: 'stream', + episode: screen.items[activeIdx], + result: null, + loading: true, + error: null, + }); + } + if (key.escape) push({ type: 'media', media: screen.media }); + } -// ─── Results screen ─────────────────────────────────────────────────────────── + if (screen.type === 'chapters') { + if (key.upArrow) setActiveIdx((i) => Math.max(0, i - 1)); + if (key.downArrow) setActiveIdx((i) => Math.min(screen.items.length - 1, i + 1)); + if (key.return && screen.items[activeIdx]) { + push({ + type: 'pages', + chapter: screen.items[activeIdx], + result: null, + loading: true, + error: null, + }); + } + if (key.escape) push({ type: 'media', media: screen.media }); + } -function ResultsScreen({ - items, - query, - metaProvider, - onSelect, - onBack, -}: { - items: IMetaSearchResult[]; - query: string; - metaProvider: MetaProviderId; - onSelect: (item: IMetaSearchResult) => void; - onBack: () => void; -}) { - const [active, setActive] = useState(0); + if (screen.type === 'stream') { + if (key.escape) + push({ + type: 'episodes', + media: { kind: 'anime' } as Media, + items: [], + loading: false, + error: null, + }); + } - useInput((input, key) => { - if (key.upArrow) setActive((i) => Math.max(0, i - 1)); - if (key.downArrow) setActive((i) => Math.min(items.length - 1, i + 1)); - if (key.return && items[active]) onSelect(items[active]); - if (key.escape || input === 'q') onBack(); + if (screen.type === 'pages') { + if (key.escape) push({ type: 'media', media: { kind: 'manga' } as Media }); + } }); + // ─── Render ──────────────────────────────────────────────────────────────── return ( - Results for - - "{query}" + + anime-sdk{' '} - ({items.length}) - - - - { - const title = preferredTitle(item.title); - const score = item.score != null ? ` ★${(item.score / 10).toFixed(1)}` : ''; - return ( - - - {isActive ? '► ' : ' '} - {truncate(title, 42)} - - {score && {score}} - - {' '} - {item.format ?? item.catalogType} - - - ); - }} - /> + 2.0 + [a]nime + + [m]anga - - - - ); -} -// ─── Media info screen ──────────────────────────────────────────────────────── - -function MediaInfoScreen({ - info, - metaProvider, - onWatch, - onBack, -}: { - info: IMediaMetadata; - metaProvider: MetaProviderId; - onWatch: () => void; - onBack: () => void; -}) { - const [tab, setTab] = useState<'overview' | 'chars' | 'staff' | 'rels'>('overview'); - const [scroll, setScroll] = useState(0); - - const title = preferredTitle(info.title); - const desc = info.description ? stripHtml(info.description) : null; - - const tabs = [ - { key: 'overview' as const, label: 'Overview' }, - ...(info.characters?.length - ? [{ key: 'chars' as const, label: `Chars(${info.characters.length})` }] - : []), - ...(info.staff?.length - ? [{ key: 'staff' as const, label: `Staff(${info.staff.length})` }] - : []), - ...(info.relations?.length - ? [{ key: 'rels' as const, label: `Relations(${info.relations.length})` }] - : []), - ]; - - useInput((input, key) => { - if (key.escape || input === 'q') onBack(); - if (input === 'w' || key.return) onWatch(); - if (input === 'c' && info.characters?.length) setTab('chars'); - if (input === 's' && info.staff?.length) setTab('staff'); - if (input === 'r' && info.relations?.length) setTab('rels'); - if (input === 'o') setTab('overview'); - if (key.downArrow) setScroll((s) => s + 1); - if (key.upArrow) setScroll((s) => Math.max(0, s - 1)); - if (input === '\t') { - const idx = tabs.findIndex((t) => t.key === tab); - setTab(tabs[(idx + 1) % tabs.length].key); - setScroll(0); - } - }); - - const meta = [ - info.format, - info.year, - info.season, - info.status, - info.score != null ? `★${(info.score / 10).toFixed(1)}` : null, - info.episodeCount != null ? `${info.episodeCount} eps` : null, - info.chapterCount != null ? `${info.chapterCount} chapters` : null, - info.durationMinutes != null ? `${info.durationMinutes}min` : null, - ] - .filter(Boolean) - .join(' '); + {screen.type === 'home' && ( + + [s] search + [b] browse trending + ctrl+c quit + + )} - return ( - - {/* Header */} - - - {truncate(title, 60)} - - {info.title.romaji && info.title.romaji !== title && ( - - {truncate(info.title.romaji, 60)} - - )} - - + {screen.type === 'search' && ( + + Search: + { + if (!q.trim()) return; + const results = await sdk.search(q.trim(), { kind }); + push({ type: 'results', items: results, query: q }); + setSearchInput(''); + }} + /> + + )} - {/* Meta row */} - - {meta} - {info.studios && info.studios.length > 0 && ( - - Studio: {info.studios.slice(0, 2).join(', ')} - - )} - {info.genres && info.genres.length > 0 && ( + {(screen.type === 'results' || screen.type === 'browse') && ( + - Genres: {info.genres.slice(0, 5).join(', ')} + {screen.type === 'results' + ? `results for "${screen.query}" (${screen.items.length})` + : `trending ${kind}`} - )} - - - - - {/* Tab bar */} - - {tabs.map((t) => ( - - {tab === t.key ? `[${t.label}]` : t.label} - - ))} - - - {/* Tab content */} - {tab === 'overview' && ( - - {desc && ( - - - {desc - .split('\n') - .slice(scroll, scroll + 6) - .join('\n')} + {screen.type === 'browse' && screen.loading && loading…} + {screen.type === 'browse' && screen.error && {screen.error}} + ( + + {isActive ? '› ' : ' '} + {truncate(m.title.preferred, 50)} + {' '} + + {m.year ? String(m.year) : ''} + {m.score ? ` ★${((m.score.value / m.score.scale) * 10).toFixed(1)}` : ''} + - - )} - {info.externalLinks && info.externalLinks.length > 0 && ( - - - Links:{' '} - {info.externalLinks - .slice(0, 5) - .map((l) => l.site) - .join(' · ')} + )} + /> + ↑↓ navigate enter select esc home + + )} + + {screen.type === 'media' && ( + + {screen.media.title.preferred} + {screen.media.title.native && {screen.media.title.native}} + + {screen.media.status && {screen.media.status}} + {screen.media.format && {screen.media.format}} + {screen.media.year && {String(screen.media.year)}} + {screen.media.score && ( + + ★ {((screen.media.score.value / screen.media.score.scale) * 10).toFixed(1)} - + )} + + {screen.media.episodeCount && ( + {screen.media.episodeCount} episodes )} - {info.streamingEpisodes && info.streamingEpisodes.length > 0 && ( - - - Episodes metadata: {info.streamingEpisodes.length} entries available - - + {screen.media.chapterCount && ( + {screen.media.chapterCount} chapters )} - {info.mappings && ( - - - Mappings:{' '} - {[ - info.mappings.anilist != null && `AniList:${info.mappings.anilist}`, - info.mappings.mal != null && `MAL:${info.mappings.mal}`, - info.mappings.kitsu != null && `Kitsu:${info.mappings.kitsu}`, - ] - .filter(Boolean) - .join(' ')} + {screen.media.description && ( + + + {truncate(stripHtml(screen.media.description), 300)} )} + + [e/r] {screen.media.kind === 'manga' ? 'read chapters' : 'watch episodes'} + esc home + )} - {tab === 'chars' && info.characters && ( + {screen.type === 'episodes' && ( - {info.characters.slice(scroll * 3, scroll * 3 + 9).map((c) => { - const va = c.voiceActors?.find((v) => v.language === 'Japanese') ?? c.voiceActors?.[0]; - return ( - - {truncate(c.name, 22)} - - {c.role ?? ''} - - {va && ( - - — {truncate(va.name, 18)} + {screen.loading && loading episodes…} + {screen.error && {screen.error}} + {!screen.loading && !screen.error && ( + <> + + {screen.items.length} episodes + + ( + + {isActive ? '› ' : ' '} + {`EP.${String(ep.number).padStart(3, '0')} `} + {truncate(ep.title ?? '', 40)} + {ep.languages.join('/')} )} - - ); - })} - - {scroll * 3 + 1}–{Math.min((scroll + 3) * 3, info.characters.length)} of{' '} - {info.characters.length} - - - )} - - {tab === 'staff' && info.staff && ( - - {info.staff.slice(scroll, scroll + 10).map((s) => ( - - {truncate(s.name, 24)} - {s.role && ( - - {s.role} - - )} - - ))} + /> + ↑↓ navigate enter stream esc back + + )} )} - {tab === 'rels' && info.relations && ( + {screen.type === 'chapters' && ( - {info.relations.slice(scroll, scroll + 8).map((r) => ( - - [{r.relationType}] - {truncate(preferredTitle(r.title), 32)} + {screen.loading && loading chapters…} + {screen.error && {screen.error}} + {!screen.loading && !screen.error && ( + <> - {r.format ?? r.catalogType} + {screen.items.length} chapters - - ))} + ( + + {isActive ? '› ' : ' '} + {`Ch.${String(ch.number).padStart(3, '0')} `} + {truncate(ch.title ?? '', 40)} + + )} + /> + ↑↓ navigate enter pages esc back + + )} )} - - - - ); -} - -// ─── Provider select screen ─────────────────────────────────────────────────── - -function ProviderSelectScreen({ - info, - onSelect, - onBack, -}: { - info: IMediaMetadata; - onSelect: (id: ContentProviderId) => void; - onBack: () => void; -}) { - const [active, setActive] = useState(0); - const title = preferredTitle(info.title); - - // Filter to anime or manga providers based on catalogType - const relevant = CONTENT_PROVIDER_IDS.filter((id) => { - const isManga = info.catalogType === 'MANGA'; - const mangaProviders = ['mangadex', 'weebcentral']; - return isManga ? mangaProviders.includes(id) : !mangaProviders.includes(id); - }); - - useInput((input, key) => { - if (key.upArrow) setActive((i) => Math.max(0, i - 1)); - if (key.downArrow) setActive((i) => Math.min(relevant.length - 1, i + 1)); - if (key.return) onSelect(relevant[active]); - if (key.escape || input === 'q') onBack(); - }); - - return ( - - - Select provider for - - {truncate(title, 30)} - - - - - {relevant.map((id, i) => ( - - - {i === active ? '► ' : ' '} - {id} - - - ))} - - - - - ); -} - -// ─── Episodes screen ────────────────────────────────────────────────────────── - -function EpisodesScreen({ - info, - contentProvider, - units, - onSelect, - onBack, -}: { - info: IMediaMetadata; - contentProvider: ContentProviderId; - units: IContentUnit[]; - onSelect: (unit: IContentUnit) => void; - onBack: () => void; -}) { - const [active, setActive] = useState(0); - const title = preferredTitle(info.title); - const isManga = info.catalogType === 'MANGA'; - - useInput((input, key) => { - if (key.upArrow) setActive((i) => Math.max(0, i - 1)); - if (key.downArrow) setActive((i) => Math.min(units.length - 1, i + 1)); - if (key.return && units[active]) onSelect(units[active]); - if (key.escape || input === 'q') onBack(); - }); - - return ( - - - - {truncate(title, 40)} - - - {' '} - — {units.length} {isManga ? 'chapters' : 'episodes'} [{contentProvider}] - - - - - { - const prefix = isManga ? 'Ch' : 'EP'; - const num = String(unit.number).padStart(3, '0'); - const flags = [unit.isFiller ? 'FILLER' : null, unit.isRecap ? 'RECAP' : null] - .filter(Boolean) - .join(' '); - return ( - - - {isActive ? '► ' : ' '} - {prefix}.{num} + {screen.type === 'stream' && ( + + {screen.loading && resolving stream…} + {screen.error && {screen.error}} + {screen.result && ( + <> + + EP.{screen.episode.number} {screen.episode.title ?? ''} + + + + [{screen.result.isHls ? 'HLS' : 'MP4'}] {screen.result.language}{' '} + {screen.result.origin.host} - {flags && ( - - [{flags}] + + {screen.result.url} + + {screen.result.qualities.length > 1 && ( + + qualities: {screen.result.qualities.map((q) => q.label).join(', ')} )} - - {truncate(unit.title, 34)} - - {unit.availableLanguages && ( - - {unit.availableLanguages.join('/')} + {screen.result.subtitles.length > 0 && ( + + subtitles: {screen.result.subtitles.map((s) => s.label).join(', ')} )} + {screen.result.adjacent.next && ( + next: EP.{screen.result.adjacent.next.number} + )} - ); - }} - /> - - - - - ); -} - -// ─── Stream result screen ───────────────────────────────────────────────────── - -function StreamResultScreen({ - info, - unit, - contentProvider, - result, - onBack, -}: { - info: IMediaMetadata; - unit: IContentUnit; - contentProvider: ContentProviderId; - result: ResolvedMediaStream; - onBack: () => void; -}) { - const [active, setActive] = useState(0); - const title = preferredTitle(info.title); - const isManga = result.type === 'manga'; - - const streams: IVideoPayload[] = result.type === 'video' ? result.streams : []; - - useInput((input, key) => { - if (key.escape || input === 'q') onBack(); - if (!isManga) { - if (key.upArrow) setActive((i) => Math.max(0, i - 1)); - if (key.downArrow) setActive((i) => Math.min(streams.length - 1, i + 1)); - } - }); - - return ( - - - - - {truncate(title, 38)} - - EP.{String(unit.number).padStart(3, '0')} - - - via {contentProvider} - - - - - {isManga && result.type === 'manga' && ( - - ✓ {result.pages.imageUrls.length} pages resolved - - {result.pages.imageUrls.slice(0, 5).map((url, i) => ( - - {i + 1}. {truncate(url, 60)} - - ))} - {result.pages.imageUrls.length > 5 && ( - - ... and {result.pages.imageUrls.length - 5} more - - )} - - {result.pages.headers && Object.keys(result.pages.headers).length > 0 && ( - - - Headers:{' '} - {Object.entries(result.pages.headers) - .map(([k, v]) => `${k}: ${v}`) - .join(', ')} - - + )} + esc back )} - {!isManga && streams.length > 0 && ( - - - ✓ {streams.length} stream{streams.length > 1 ? 's' : ''} resolved - - - {streams.map((s, i) => ( - - - - {i === active ? '●' : '○'} [{s.isHLS ? 'HLS' : 'MP4'}] {s.quality} - {s.language ? ` ${s.language}` : ''} - - {s.subtitles && s.subtitles.length > 0 && ( - - {s.subtitles.length} sub{s.subtitles.length > 1 ? 's' : ''} - - )} - - - {' '} - {truncate(s.sourceUrl, 58)} + {screen.type === 'pages' && ( + + {screen.loading && loading pages…} + {screen.error && {screen.error}} + {screen.result && ( + <> + + Ch.{screen.chapter.number} {screen.chapter.title ?? ''} + + + {screen.result.pages.length} pages origin:{' '} + {screen.result.pages[0]?.origin.host ?? ''} + + {screen.result.pages.slice(0, 3).map((p, i) => ( + + {p.url} - {i === active && s.headers && Object.keys(s.headers).length > 0 && ( - - {' '}headers: {Object.keys(s.headers).join(', ')} - - )} - {i === active && s.subtitles && s.subtitles.length > 0 && ( - - {s.subtitles.slice(0, 3).map((sub, j) => ( - - {' '}sub [{sub.label}]: {truncate(sub.url, 48)} - - ))} - - )} - - ))} - + ))} + {screen.result.pages.length > 3 && ( + … +{screen.result.pages.length - 3} more + )} + + )} + esc back )} - - - ); } -// ─── Root app ───────────────────────────────────────────────────────────────── - -function App() { - const [screen, setScreen] = useState({ type: 'home' }); - const [metaProvider, setMetaProvider] = useState('anilist'); - const [history, setHistory] = useState([]); - - const push = useCallback( - (next: Screen) => { - setHistory((h) => [...h, screen]); - setScreen(next); - }, - [screen], - ); - - const back = useCallback(() => { - const prev = history[history.length - 1]; - if (prev) { - setHistory((h) => h.slice(0, -1)); - setScreen(prev); - } - }, [history]); - - const loadMedia = useCallback( - async (item: IMetaSearchResult) => { - const loading: Screen = { - type: 'media', - info: null as unknown as IMediaMetadata, - tab: 'overview', - metaProvider, - }; - push(loading); - try { - const info = await META_PROVIDERS[metaProvider].fetchMediaInfo(item.id); - setScreen({ type: 'media', info, tab: 'overview', metaProvider }); - } catch (e) { - back(); - } - }, - [metaProvider, push, back], - ); - - const loadEpisodes = useCallback( - async (info: IMediaMetadata, contentProviderId: ContentProviderId) => { - const provider = CONTENT_PROVIDERS[contentProviderId]; - const loadingScreen: Screen = { - type: 'episodes', - info, - contentProvider: contentProviderId, - units: [], - loading: true, - error: null, - }; - push(loadingScreen); - try { - const units = await META_PROVIDERS[metaProvider].fetchContentUnits(info.id, provider); - setScreen({ ...loadingScreen, units, loading: false }); - } catch (e) { - setScreen({ - ...loadingScreen, - loading: false, - error: e instanceof Error ? e.message : String(e), - }); - } - }, - [metaProvider, push], - ); - - const resolveStream = useCallback( - async (info: IMediaMetadata, unit: IContentUnit, contentProviderId: ContentProviderId) => { - const loadingScreen: Screen = { - type: 'stream', - info, - unit, - contentProvider: contentProviderId, - result: null, - loading: true, - error: null, - }; - push(loadingScreen); - try { - const provider = CONTENT_PROVIDERS[contentProviderId]; - const lang = unit.availableLanguages?.[0] ?? 'sub'; - const result = await provider.resolveStream(unit.id, lang as 'sub' | 'dub' | 'raw'); - setScreen({ ...loadingScreen, result, loading: false }); - } catch (e) { - setScreen({ - ...loadingScreen, - loading: false, - error: e instanceof Error ? e.message : String(e), - }); - } - }, - [push], - ); - - if (screen.type === 'home') { - return ( - { - if (action === 'browse-trending') - push({ - type: 'browse', - kind: 'trending', - metaProvider, - loading: true, - items: [], - error: null, - }); - if (action === 'browse-popular') - push({ - type: 'browse', - kind: 'popular', - metaProvider, - loading: true, - items: [], - error: null, - }); - if (action === 'browse-seasonal') - push({ - type: 'browse', - kind: 'seasonal', - metaProvider, - loading: true, - items: [], - error: null, - }); - if (action === 'search') push({ type: 'search', metaProvider }); - }} - /> - ); - } - - if (screen.type === 'browse') { - return ( - - ); - } - - if (screen.type === 'search') { - return ( - - push({ type: 'results', items, query, metaProvider: screen.metaProvider }) - } - onBack={back} - /> - ); - } - - if (screen.type === 'results') { - return ( - - ); - } - - if (screen.type === 'media') { - if (!screen.info) { - return ( - - Loading media info... - - ); - } - return ( - - push({ type: 'provider-select', info: screen.info, metaProvider: screen.metaProvider }) - } - onBack={back} - /> - ); - } - - if (screen.type === 'provider-select') { - return ( - loadEpisodes(screen.info, id)} - onBack={back} - /> - ); - } - - if (screen.type === 'episodes') { - if (screen.loading) { - return ( - - Resolving episodes via {screen.contentProvider}... - - (cross-source mapping may take a few seconds) - - - ); - } - if (screen.error) { - return ( - - {screen.error} - Press Esc to go back - - ); - } - return ( - resolveStream(screen.info, unit, screen.contentProvider)} - onBack={back} - /> - ); - } - - if (screen.type === 'stream') { - if (screen.loading) { - return ( - - Resolving stream... - - ); - } - if (screen.error || !screen.result) { - return ( - - {screen.error ?? 'No result'} - Press Esc to go back - - ); - } - return ( - - ); - } - - return null; -} - -// Boot the app — need stdin in raw mode for key capture -const { stdin } = process; -if (stdin.isTTY) stdin.setRawMode(true); - render(); diff --git a/website/src/components/islands/CodeEditorScene.tsx b/website/src/components/islands/CodeEditorScene.tsx index 7407193..905815e 100644 --- a/website/src/components/islands/CodeEditorScene.tsx +++ b/website/src/components/islands/CodeEditorScene.tsx @@ -1,24 +1,24 @@ import { useState, useEffect } from 'react'; const CODE_LINES: string[] = [ - `import { HttpClient, AllmangaProvider } from 'anime-sdk';`, + `import { createSdk } from 'anime-sdk';`, ``, - `const provider = new AllmangaProvider(new HttpClient({ timeoutMs: 25_000 }));`, + `const sdk = createSdk(); // zero config`, ``, `// 1. Search for an anime title`, - `const hits = await provider.search('Frieren');`, - `// → [{ id: 'frieren-beyond', title: "Frieren: Beyond Journey's End" }]`, + `const [show] = await sdk.search('Frieren', { kind: 'anime' });`, + `// → { title: { preferred: "Frieren: Beyond Journey's End" } }`, ``, `// 2. Fetch episode list`, - `const eps = await provider.fetchContentUnits(hits[0].id, 'sub');`, - `// → [{ id: 'ep-1', title: 'Episode 1' }, { id: 'ep-2', ... }, ...]`, + `const { items } = await sdk.episodes(show);`, + `// → [{ id: '...', number: 1, languages: ['sub', 'dub'] }, ...]`, ``, `// 3. Resolve a direct stream URL`, - `const stream = await provider.resolveStream(eps[0].id);`, - `// → { streams: [{ sourceUrl: 'https://...', quality: '1080p' }] }`, + `const stream = await sdk.stream(items[0], { language: 'sub' });`, + `// → { url, origin, isHls, qualities, adjacent }`, ``, - `console.log(stream.streams[0].sourceUrl);`, - `// "https://v2.vidsrc.me/stream/frieren-ep1-720p.m3u8"`, + `console.log(stream.url);`, + `// "https://cdn.example.com/frieren-ep1.m3u8"`, ]; const CODE_LINES_PLAIN = CODE_LINES.map((l) => l.replace(/<[^>]+>/g, '')); diff --git a/website/src/components/islands/ProviderDashboardScene.tsx b/website/src/components/islands/ProviderDashboardScene.tsx index 9526211..7d71b27 100644 --- a/website/src/components/islands/ProviderDashboardScene.tsx +++ b/website/src/components/islands/ProviderDashboardScene.tsx @@ -115,7 +115,7 @@ export default function ProviderDashboardScene() { display: 'inline-block', }} /> - AllmangaProvider + allmanga - resolveStream → - https://v2.vidsrc.me/stream/frieren-ep1.m3u8 + stream.url → + https://cdn.example.com/frieren-ep1.m3u8
); diff --git a/website/src/components/islands/TerminalScene.tsx b/website/src/components/islands/TerminalScene.tsx index 141b4ca..d9bbeab 100644 --- a/website/src/components/islands/TerminalScene.tsx +++ b/website/src/components/islands/TerminalScene.tsx @@ -8,22 +8,22 @@ type TLine = | { t: 'ok'; v: string }; const TERMINAL_LINES: TLine[] = [ - { t: 'cmd', v: 'pnpm test:e2e --reporter=verbose' }, + { t: 'cmd', v: 'npx vitest run tests/e2e --reporter=verbose' }, { t: 'blank' }, - { t: 'file', v: 'RUNNING tests/allmanga.e2e.ts' }, - { t: 'pass', v: 'search("Frieren") → 1 result', time: '3.2s' }, - { t: 'pass', v: 'fetchContentUnits → 28 episodes', time: '1.8s' }, - { t: 'pass', v: 'resolveStream → HLS url', time: '2.1s' }, + { t: 'file', v: 'RUNNING tests/e2e/allmanga.test.ts' }, + { t: 'pass', v: 'search("Frieren") → Media[]', time: '3.2s' }, + { t: 'pass', v: 'episodes() → 28 Episode items', time: '1.8s' }, + { t: 'pass', v: 'stream() → HLS url', time: '2.1s' }, { t: 'pass', v: 'ffmpeg screenshot → 45.2 KB', time: '4.4s' }, { t: 'blank' }, - { t: 'file', v: 'RUNNING tests/gogoanime.e2e.ts' }, - { t: 'pass', v: 'search("One Piece") → 1 result', time: '4.1s' }, - { t: 'pass', v: 'resolveStream → HLS url', time: '3.3s' }, + { t: 'file', v: 'RUNNING tests/e2e/gogoanime.test.ts' }, + { t: 'pass', v: 'search("One Piece") → Media[]', time: '4.1s' }, + { t: 'pass', v: 'stream() → HLS url', time: '3.3s' }, { t: 'pass', v: 'ffmpeg screenshot → 23.1 KB', time: '5.2s' }, { t: 'blank' }, - { t: 'file', v: 'RUNNING tests/goyabu.e2e.ts' }, - { t: 'pass', v: 'search("Naruto") → 1 result', time: '2.9s' }, - { t: 'pass', v: 'resolveStream → MP4 url', time: '1.4s' }, + { t: 'file', v: 'RUNNING tests/e2e/goyabu.test.ts' }, + { t: 'pass', v: 'search("Naruto") → Media[]', time: '2.9s' }, + { t: 'pass', v: 'stream() → MP4 url', time: '1.4s' }, { t: 'pass', v: 'ffmpeg screenshot → 31.7 KB', time: '3.8s' }, { t: 'blank' }, { t: 'ok', v: 'Tests: 9 passed · 9 total · Duration: 31.3s' }, diff --git a/website/src/components/sections/Contribute.astro b/website/src/components/sections/Contribute.astro index a3b1d06..e0898d5 100644 --- a/website/src/components/sections/Contribute.astro +++ b/website/src/components/sections/Contribute.astro @@ -9,9 +9,11 @@ class="mx-auto mb-4 text-[13px] leading-relaxed" style="color: var(--muted); max-width: 420px;" > - Add a provider in ~100 lines. Extend BaseProvider, implement three methods and write a test. + Add a source in ~100 lines. Implement the Source interface, register it in createSdk(), and write a live E2E test.

import { HttpClient, MegaPlayProvider } from 'anime-sdk'; +const demoCode = `import { createSdk } from 'anime-sdk'; -const provider = new MegaPlayProvider( - new HttpClient({ timeoutMs: 10_000 }) -); +const sdk = createSdk(); // zero config -const shows = await provider.search('Frieren'); -const units = await provider.fetchContentUnits(shows[0].id); -const result = await provider.resolveStream(units[0].id); +// search → episodes → stream +const [show] = await sdk.search('Frieren', { kind: 'anime' }); +const { items } = await sdk.episodes(show); +const stream = await sdk.stream(items[0], { language: 'sub' }); -if (result.type === 'video') console.log(result.streams[0].sourceUrl); -if (result.type === 'manga') console.log(result.pages.imageUrls);`; +console.log(stream.url); // playable URL +console.log(stream.origin.host); // origin hostname +console.log(stream.adjacent.next); // next episode id`; ---

Setup in minutes.

- Every provider exposes{' '} + One import. Three calls.{' '} search {' → '} - fetchContentUnits + episodes {' → '} - resolveStream - . Swap the provider, the three calls don't change. + stream + . All 12 sources behind a single API.

@@ -36,18 +36,18 @@ const megaplayCode = `import {$ npm install anime-sdk
- Provider · megaplay + 2.0 · createSdk example.ts
-
+      
       

- Want sub/dub? Pass{' '} + Pass{' '} 'sub' | 'dub' | 'raw' {' '}to{' '} - resolveStream - . Need a different source? Swap the provider; same three calls. + stream() + . All types are plain POJOs — safe for React state, Redux, Zustand.

startServer({ - providers: [new GogoanimeProvider(new HttpClient())], - port: 3000, - proxy: true, // ← that's it +const proxyEnableCode = `// proxy on by default with npx anime-sdk +// or programmatically: +import { startServerV2, createSdk } from 'anime-sdk'; +startServerV2({ + sdk: createSdk({ + proxy: { signSecret: process.env.PROXY_SECRET }, + }), });`; const proxyRewriteCode = `// without proxy { - sourceUrl: "https://cdn.example.com/ep1.m3u8", - headers: { Referer: "https://provider.com/watch/..." } + url: "https://cdn.example.com/ep1.m3u8", + headers: { Referer: "https://provider.com" }, + origin: { host: "cdn.example.com", proxied: false } } // with proxy { - sourceUrl: "http://localhost:3000/proxy?url=...&h=..." + url: "http://localhost:3030/proxy?url=...&sig=...", + origin: { host: "cdn.example.com", proxied: true } }`; -const proxyPlayerCode = `// just pass sourceUrl straight to hls.js +const proxyPlayerCode = `// just pass stream.url straight to hls.js const hls = new Hls(); -hls.loadSource(sourceUrl); +hls.loadSource(stream.url); hls.attachMedia(videoEl); -// no xhrSetup. no custom headers. +// stream.origin.host shows the real CDN hostname +// no xhrSetup. no custom headers needed. // segment + key URLs rewrite automatically.`; --- @@ -34,11 +40,11 @@ hls.attachMedia Most stream CDNs require a Referer header browsers can't send freely. Set header browsers can't send freely. Enable the proxy and every URL in the response becomes a + plain proxied URL your player can use directly. proxy: true and every URL in the response becomes a plain proxied URL your player can use directly. No header - wrangling. + style="color: var(--accent);">stream.origin.host always tells you where it really came from.

@@ -52,9 +58,9 @@ hls.attachMedia

A /proxy endpoint becomes active - and every sourceUrl in url in /stream/episode/:id/stream responses is rewritten to go through it.

@@ -65,10 +71,13 @@ hls.attachMedia

- Required headers are base64-encoded into the URL. Your frontend uses sourceUrl as-is. No header forwarding needed. + style="color: var(--accent);">stream.url as-is. The origin is always accessible in stream.origin.host.

diff --git a/website/src/components/sections/Server.astro b/website/src/components/sections/Server.astro index a5a5653..aa2d766 100644 --- a/website/src/components/sections/Server.astro +++ b/website/src/components/sections/Server.astro @@ -1,34 +1,29 @@ --- -const serverStartCode = `import { startServer, AllmangaProvider, HttpClient } from 'anime-sdk'; +const serverStartCode = `# zero-install server — all 12 sources, proxy on +$ npx anime-sdk -const store = new Map(); +// or programmatically: +import { startServerV2 } from 'anime-sdk/server'; +await startServerV2({ port: 3030 }); -startServer({ - providers: [new AllmangaProvider(new HttpClient())], - port: 3000, - proxy: true, - cache: { get: (k) => store.get(k), set: (k, v) => store.set(k, v) }, -}); +// → http://localhost:3030`; -// → http://localhost:3000`; +const serverCurlCode = `# search anime or manga +curl 'localhost:3030/search?q=Frieren&kind=anime' -const serverCurlCode = `# search shows or manga -curl 'localhost:3000/search?q=Frieren&provider=mangadex' +# episodes or chapters +curl 'localhost:3030/media/:id/episodes' -# content: episodes or chapters -curl 'localhost:3000/content?mediaId=...&provider=mangadex' +# resolve stream +curl 'localhost:3030/episode/:id/stream?language=sub'`; -# resolve: streams or image pages -curl 'localhost:3000/stream?unitId=...&provider=mangadex'`; +const serverAuthCode = `$ PORT=8080 \\ + SOURCES_DISABLED=goyabu \\ + npx anime-sdk -const serverAuthCode = `startServer({ - providers: [new AllmangaProvider(new HttpClient())], - port: 3000, - auth: { token: process.env.API_TOKEN }, -}); - -// Clients send: -// Authorization: Bearer <token>`; +// all sources, with SDK instance: +import { createSdk, startServerV2 } from 'anime-sdk'; +startServerV2({ sdk: createSdk({ cache })});`; ---
@@ -38,9 +33,9 @@ const serverAuthCode = `startServerSkip the API boilerplate.

- anime-sdk ships with a ready-to-run HTTP server. Routes for search, episodes, streams, and - track metadata; an optional stream + subtitle proxy; bring-your-own cache with a two-method - get/set interface; bearer-token auth. Point any client at it, ship. + anime-sdk ships with a zero-config HTTP server. One command or one line of code. Routes for + search, episodes, streams, manga pages, and browse; bring-your-own cache with a two-method + get/set interface. Point any client at it, ship.

@@ -49,7 +44,7 @@ const serverAuthCode = `startServer
Start it - server.ts + terminal / server.ts
     
@@ -67,12 +62,12 @@ const serverAuthCode = `startServer
- Lock it down + Configure optional
       

- Skip auth entirely if it's local. The SDK works standalone too. No server required. + Skip the server entirely and call the SDK directly. No HTTP required.

diff --git a/website/src/pages/faq.astro b/website/src/pages/faq.astro index 4e6b50c..4be7439 100644 --- a/website/src/pages/faq.astro +++ b/website/src/pages/faq.astro @@ -13,16 +13,16 @@ const faqs = [ a: 'No. anime-sdk runs in Node or Bun only. For a browser frontend, run the built-in HTTP server on your backend and call it over HTTP from the client. Set proxy: true when starting the server — it rewrites stream URLs so your frontend never has to deal with CDN headers or CORS.', }, { - q: 'I got a sourceUrl back from resolveStream. How do I play it?', - a: "Check isHLS. If true, the URL is an HLS stream — use hls.js on Chrome or Firefox, or a native <video> on Safari. If false, it's a plain MP4 you can set directly as the video source. If you're running the HTTP server with proxy: true, all of this is handled for you and the URL just works.", + q: 'I got a stream back from sdk.stream(). How do I play it?', + a: "Check stream.isHls. If true, use hls.js on Chrome or Firefox, or a native <video> on Safari. If false, set stream.url directly as the video source. stream.origin.host tells you where it really came from. If you're running the HTTP server, all URLs are already proxied and work directly in the browser.", }, { - q: 'Which provider should I use?', - a: 'For general anime, start with AllManga — widest catalogue, sub and dub, stable API. AnimeParadise is the best pick when you need external subtitle tracks. Gogoanime is a solid fallback for titles AllManga is missing. Goyabu is the only option for Brazilian Portuguese. For manga, MangaDex first, then WeebCentral or Mangapill.', + q: 'Which source gives the best results?', + a: "createSdk() picks the best available source automatically. If you want to target a specific one, pass sources: ['allmanga'] in the config. AllManga has the widest anime catalogue with sub and dub. AnimeParadise is best for external subtitle tracks. MangaDex is the canonical manga source.", }, { - q: 'Which providers have dubbed anime?', - a: "AllManga, Anikoto, and MegaPlay support sub and dub. Goyabu is Brazilian Portuguese dub only. Gogoanime and AnimeParadise are sub-only. Pass the language when calling resolveStream — if a provider doesn't support what you asked for, it falls back to sub.", + q: 'Which sources have dubbed anime?', + a: "AllManga, Anikoto, and MegaPlay support sub and dub. Goyabu is Brazilian Portuguese dub only. Gogoanime and AnimeParadise are sub-only. Pass language: 'dub' to sdk.stream() — if the source doesn't have dub for that episode, you'll get an error.", }, { q: 'Do stream URLs expire?', @@ -37,8 +37,8 @@ const faqs = [ a: 'anime-sdk resolves stream URLs from sites that host content publicly. It does not bypass DRM or crack paywalls. Whether your specific use is legal depends on your jurisdiction and what you are building — a personal tool is a different situation from a public redistribution service.', }, { - q: 'What happens when a provider goes down?', - a: 'Swap it out. All providers share the same three-method interface, so switching is a one-line import change. The live E2E tests hit real endpoints and fail the moment an upstream site breaks, which is how we catch when a provider needs updating.', + q: 'What happens when a source goes down?', + a: "The SDK tries the next best available source automatically. If you need to disable a specific source, pass disabled: ['source-id'] to createSdk(). The live E2E tests hit real endpoints and fail the moment an upstream site breaks, which is how we catch when a source needs updating.", }, { q: 'Can I download episodes and chapters to disk?', diff --git a/website/src/pages/og/[...slug].png.ts b/website/src/pages/og/[...slug].png.ts index 34ce3c2..b23ae04 100644 --- a/website/src/pages/og/[...slug].png.ts +++ b/website/src/pages/og/[...slug].png.ts @@ -57,19 +57,19 @@ export const pages: Record Date: Fri, 19 Jun 2026 06:30:42 +0100 Subject: [PATCH 16/19] docs: rewrite all marketing + documentation content for 2.0 API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All references to 1.x API (BaseProvider, fetchContentUnits, resolveStream, AllmangaProvider, HttpClient exports, IVideoPayload, startServer with providers array, URN strings, 9 providers) removed or updated to 2.0 equivalents. Getting Started (index.mdx): createSdk() → search/episodes/stream flow, plain POJO types, progressive search, AniError, download utilities. HTTP Server (http-server.mdx): startServerV2 / npx anime-sdk, all 9 routes (/search, /media/:id, /episodes, /chapters, /sources, /episode/:id/stream, /chapter/:id/pages, /browse, /health) with request/response shapes. API Reference (api-reference.mdx): complete 2.0 surface — createSdk, SdkOptions, Sdk methods, all value types (Media, Episode, Chapter, Stream, Pages, List, SourceInfo, Score), AniError/AniErrorCode, startServerV2, downloadVideo/downloadMangaChapter. Contributing (contributing.mdx): Source interface instead of BaseProvider, encodeId/decodeId, streamToPayload, updated checklist. Proxy (proxy.mdx): createSdk({ proxy }) instead of startServer + proxy: true, stream.url / stream.origin.host / stream.origin.proxied. Download (download.mdx): sdk.stream() → downloadVideo, sdk.pages() → downloadMangaChapter. Providers overview: renamed "Providers" → "Sources", updated tables, 12 total. All 12 individual source docs: createSdk({ sources: [...] }) pattern, removed old Provider constructor usage. Hero: "9 providers" → "12 sources", updated description. HeroStats: "9 Providers" → "12 Sources". Layout default description: updated. --- website/src/components/Hero.astro | 4 +- website/src/components/HeroStats.astro | 4 +- .../src/content/docs/docs/api-reference.mdx | 897 +++++------------- .../src/content/docs/docs/contributing.mdx | 327 +++---- website/src/content/docs/docs/download.mdx | 165 +--- website/src/content/docs/docs/http-server.mdx | 427 ++++----- website/src/content/docs/docs/index.mdx | 257 +++-- .../content/docs/docs/providers/allmanga.mdx | 106 +-- .../content/docs/docs/providers/anikoto.mdx | 46 +- .../content/docs/docs/providers/anilist.mdx | 67 +- .../docs/docs/providers/animeparadise.mdx | 47 +- .../content/docs/docs/providers/gogoanime.mdx | 82 +- .../content/docs/docs/providers/goyabu.mdx | 80 +- .../src/content/docs/docs/providers/index.mdx | 123 +-- .../src/content/docs/docs/providers/kitsu.mdx | 76 +- .../src/content/docs/docs/providers/mal.mdx | 64 +- .../content/docs/docs/providers/mangadex.mdx | 42 +- .../content/docs/docs/providers/mangapill.mdx | 33 +- .../content/docs/docs/providers/megaplay.mdx | 56 +- .../docs/docs/providers/weebcentral.mdx | 35 +- website/src/content/docs/docs/proxy.mdx | 228 ++--- website/src/layouts/Layout.astro | 2 +- 22 files changed, 1099 insertions(+), 2069 deletions(-) diff --git a/website/src/components/Hero.astro b/website/src/components/Hero.astro index fa333ff..a48afb9 100644 --- a/website/src/components/Hero.astro +++ b/website/src/components/Hero.astro @@ -39,8 +39,8 @@ import Notification from './islands/Notification'; We'll handle the streams.

- A TypeScript library for resolving anime streams and manga pages across 9 providers. Use it as - a Node module, run the built-in HTTP server, or download episodes and chapters to disk. + A TypeScript SDK for searching anime and manga across 12 sources and resolving direct stream + URLs. One import, nine methods. Use the SDK directly or run the built-in HTTP server.

-
9 Providers
+
12 Sources
- don't get locked into a single source, switch any time + catalogue sources (AniList, MAL, Kitsu) + anime & manga playback
diff --git a/website/src/content/docs/docs/api-reference.mdx b/website/src/content/docs/docs/api-reference.mdx index 3c2d93c..a6ddd0e 100644 --- a/website/src/content/docs/docs/api-reference.mdx +++ b/website/src/content/docs/docs/api-reference.mdx @@ -1,829 +1,402 @@ --- title: API Reference -description: Complete type and interface documentation for anime-sdk. +description: Complete type and function documentation for anime-sdk 2.0. --- -## Types - -### `ContentLanguage` +## `createSdk(opts?)` ```ts -type ContentLanguage = 'sub' | 'dub' | 'raw'; +import { createSdk } from 'anime-sdk'; +const sdk = createSdk(opts?); ``` -| Value | Meaning | -| ------- | ------------------------------ | -| `'sub'` | Original audio with subtitles | -| `'dub'` | Dubbed audio (usually English) | -| `'raw'` | Original audio, no subtitles | +Factory — instantiates the SDK with all 12 sources registered. -### `MediaCatalogType` +### `SdkOptions` ```ts -type MediaCatalogType = 'ANIME' | 'MOVIE' | 'TV' | 'MANGA'; +interface SdkOptions { + sources?: string[]; // whitelist source IDs; default: all 12 + disabled?: string[]; // blacklist a subset + http?: { + timeoutMs?: number; // default: 30000 + retries?: number; // default: 3 + userAgent?: string; + }; + proxy?: { + signSecret?: string; // HMAC-sign proxy URLs + allowedHosts?: string[]; // SSRF suffix-matched allowlist + }; + cache?: { + get(k: string): unknown; + set(k: string, v: unknown): void; + }; + ratelimit?: Record; // hostname → req/sec override +} ``` -Supported types include `'ANIME'` and `'MANGA'`. - -### `Urn` - -```ts -type Urn = string; -``` +--- -Every `id` flowing in or out of the SDK is a URN of shape -`${providerId}:${rawId}`. The first colon is the separator; the raw -portion is opaque and may itself contain colons or slashes. See the -[URN helpers](#urn-helpers) section for builders/parsers. +## Sdk methods -### `CallOptions` +### `sdk.search(query, opts?)` ```ts -interface CallOptions { - /** Cancels the in-flight call. Threaded into fetch + rate limiter + retry. */ +sdk.search(query: string, opts?: { + kind?: 'anime' | 'manga'; // default: 'anime' signal?: AbortSignal; - /** Meta-layer only: throw on a missing episode instead of falling back. */ - strictEpisodeMatching?: boolean; - /** Meta-layer only: behaviour of the absolute-episode rescue. */ - episodeAbsoluteMatching?: 'auto' | 'always' | 'never'; -} +}): ProgressiveResult ``` -The canonical options bag — every public method in the SDK (content and -metadata providers, mapping client) accepts an instance. Fields that -aren't meaningful at a particular layer are simply ignored there. - -### `IMediaSearchResult` +Returns a value that is both `AsyncIterable` (iterate for results as they arrive) and `PromiseLike` (await for the full array). Calls `.cancel()` to abort. -Returned by `provider.search()`. +### `sdk.info(media, opts?)` ```ts -interface IMediaSearchResult { - id: Urn; // `${providerId}:${rawId}` — pass to fetchContentUnits - title: string; - thumbnailUrl?: string; - catalogType: MediaCatalogType; - providerId: string; // matches provider.id - availableLanguages?: ContentLanguage[]; // omitted if unknown - /** Year of release, when the provider exposes it. Used by the - * metadata layer's fuzzy matcher as a discriminator. */ - year?: number; -} +sdk.info(media: Media | string, opts?: { signal?: AbortSignal }): Promise ``` -### `IContentUnit` +Full info for a single title. Accepts a `Media` object or an opaque `id` string. -Returned by `provider.fetchContentUnits()`. Represents a single episode or chapter in a unified, language-agnostic list: the caller picks a translation at `resolveStream` time. +### `sdk.sources(media, opts?)` ```ts -interface IContentUnit { - id: Urn; // pass to resolveStream - title: string; - number: number; // episode/chapter number (may be fractional, e.g. 1.5) - availableLanguages?: ContentLanguage[]; // e.g. ['sub', 'dub']: omitted if unknown - availableSubtitles?: ISubtitleAvailability[]; // optional, list-time metadata - availableQualities?: IVideoPayload['quality'][]; // optional - /** Per-episode metadata folded in by the metadata layer - * (AniList `streamingEpisodes`, Jikan filler/recap flags). */ - thumbnailUrl?: string; - description?: string; - airDate?: string; - isFiller?: boolean; - isRecap?: boolean; -} +sdk.sources(media: Media | string, opts?: { signal?: AbortSignal }): Promise ``` -### `ISubtitleTrack` / `ISubtitleAvailability` - -```ts -interface ISubtitleAvailability { - language: string; // BCP-47 (e.g. 'en', 'pt-BR') - label: string; // human-readable ('English') - format?: 'vtt' | 'srt' | 'ass'; -} - -interface ISubtitleTrack extends ISubtitleAvailability { - url: string; -} -``` +Returns playback sources ranked by health × coverage. `status: 'available'` means the source has this title. -`ISubtitleAvailability` is the metadata-only shape used in list contexts; `ISubtitleTrack` adds the URL once resolved. - -### `IUnitTracks` - -Returned by the optional `provider.fetchUnitTracks()`. Lets a UI introspect tracks without paying the cost of a full stream resolution. +### `sdk.episodes(media, opts?)` ```ts -interface IUnitTracks { - subtitles: ISubtitleTrack[]; - qualities: IVideoPayload['quality'][]; - headers?: Record; // forwarded to the subtitle/stream fetcher when present -} +sdk.episodes(media: Media | string, opts?: { + signal?: AbortSignal; + cursor?: string; + limit?: number; +}): Promise> ``` -### `IVideoPayload` - -A single playable stream. +### `sdk.chapters(media, opts?)` ```ts -interface IVideoPayload { - sourceUrl: string; - isHLS: boolean; // true → HLS manifest (.m3u8) - quality: '1080p' | '720p' | '480p' | '360p' | 'auto'; - language?: ContentLanguage; - headers?: Record; // forward to fetch/player (Referer, User-Agent) - subtitles?: ISubtitleTrack[]; // external VTT tracks when the provider has them -} -``` - -### `SdkCache` - -Minimal pluggable cache contract: bring whatever store you want (`Map`, Redis, SQLite, edge KV). Both methods may be sync or async; the SDK awaits either way. - -```ts -interface SdkCache { - get(key: string): unknown | Promise; - set(key: string, value: unknown): void | Promise; -} +sdk.chapters(media: Media | string, opts?: { + signal?: AbortSignal; + cursor?: string; + limit?: number; +}): Promise> ``` -Keys are namespaced strings produced by `startServer`: `search::`, `content::`, `stream:::`, `tracks:::`. `get` returns `undefined` for a miss; any other value (including `null`) is treated as a hit. Inspect the key prefix in your `set` to apply different TTLs per endpoint or refuse to cache (e.g. `stream:` if your provider hands out signed expiring URLs). - -### `IMangaPayload` +### `sdk.stream(episode, opts?)` ```ts -interface IMangaPayload { - imageUrls: string[]; - headers?: Record; -} +sdk.stream(episode: Episode | string, opts?: { + language?: 'sub' | 'dub' | 'raw'; + quality?: string; + adjacency?: 'within-media' | 'walk-relations'; // default: 'walk-relations' + signal?: AbortSignal; +}): Promise ``` -### `ResolvedMediaStream` +`adjacency: 'walk-relations'` follows SEQUEL/PREQUEL relations to populate `stream.adjacent` at season boundaries. `'within-media'` stops at the last episode. -Returned by `provider.resolveStream()`. A discriminated union: always check `type` before accessing the payload. +### `sdk.pages(chapter, opts?)` ```ts -type ResolvedMediaStream = - | { type: 'video'; streams: IVideoPayload[] } - | { type: 'manga'; pages: IMangaPayload }; +sdk.pages(chapter: Chapter | string, opts?: { signal?: AbortSignal }): Promise ``` -Anime providers return `type: 'video'`; manga providers return `type: 'manga'`. The `streams` array on video results is sorted best-first by the provider. - ---- - -## HttpClient +No `language` argument — manga has no language axis. -The shared HTTP transport. All providers and extractors accept one via constructor. +### `sdk.browse(opts)` ```ts -class HttpClient { - constructor(config?: HttpClientConfig); -} - -interface HttpClientConfig { - timeoutMs?: number; // default: 10000 (10s) - proxyUrl?: string; // e.g. 'https://proxy.example.com' - proxyType?: 'prepend' | 'query'; // default: 'prepend' - proxyQueryParam?: string; // used when proxyType='query'; default: 'url' - defaultHeaders?: Record; - /** Per-host token-bucket rate limits. Merged on top of DEFAULT_RATE_LIMITS - * (AniList / Jikan / Kitsu / MALSync / Anify / arm-server). */ - rateLimits?: PerHostRateLimits; - defaultRateLimit?: RateLimitConfig; - disableRateLimit?: boolean; - /** Retry policy. Defaults to 3 attempts with exponential backoff on - * 408/425/429/5xx and transient network errors. Honours Retry-After. */ - retry?: RetryConfig | false; - /** Pluggable transport. Defaults to CurlFallbackTransport on Node. */ - transport?: HttpTransport; -} +sdk.browse(opts: { + list: 'trending' | 'popular' | 'seasonal' | 'top'; + kind: 'anime' | 'manga'; + signal?: AbortSignal; + page?: number; + perPage?: number; + season?: string; // required for 'seasonal' + year?: number; // required for 'seasonal' +}): Promise> ``` -### Rate limiting + retry + AbortSignal - -`HttpClient.request` composes three middlewares around the underlying -transport, each on by default: - -- **Per-host token-bucket rate limiter** (`RateLimiter`) with built-in - policies for the catalogue APIs (`DEFAULT_RATE_LIMITS`). Re-acquired - on every retry so a retry storm can't blow past the configured cap. -- **Exponential-backoff retry** (`withRetry`) that honours `Retry-After` - on `408/425/429/5xx` responses and transient network errors. Throws - `HttpRetryableError` after exhausting attempts. -- **AbortSignal composition**: the caller's `options.signal` and the - internal timeout signal are merged — either one aborts the in-flight - request. +### `sdk.health()` ```ts -const ac = new AbortController(); -setTimeout(() => ac.abort(), 1500); -await http.get('https://graphql.anilist.co', { signal: ac.signal }); +sdk.health(): SourceHealth[] ``` -### Pluggable transport - -`HttpTransport` is a one-method interface: `fetch(url, init)`. Two -implementations ship: - -- **`CurlFallbackTransport`** (default on Node): tries `fetch` first, falls - back to `curl` via `child_process.execSync` on network error. -- **`FetchTransport`**: plain `fetch`. Use on Workers/Deno or in tests - where the curl fallback is undesirable. - -Bring your own (Undici dispatcher, Cloudflare-bypass proxy, in-process -test transport) by implementing the interface and passing it as -`transport`. - -### Methods - -```ts -get(url: string, options?: RequestInit): Promise -post(url: string, body?: any, options?: RequestInit): Promise -request(url: string, options?: RequestInit): Promise - -// Convenience mutators (affect defaultHeaders) -setCookie(name: string, value: string): void -setUserAgent(userAgent: string): void - -// Proxy internals (used by HlsUtils) -getProxyUrl(): string | undefined -getProxyType(): 'prepend' | 'query' -getProxyQueryParam(): string -getDefaultHeaders(): Record -requestUrl(url: string): string // applies proxy rewriting to a URL -``` - -### Proxy modes - -**`prepend`**: strips the protocol from the target and prepends the proxy base: - -``` -proxyUrl: 'https://proxy.example.com' -target: 'https://cdn.site.com/video.m3u8' -result: 'https://proxy.example.com/cdn.site.com/video.m3u8' -``` - -**`query`**: passes the target as a query param: - -``` -proxyUrl: 'https://proxy.example.com/fetch' -proxyQueryParam: 'url' -result: 'https://proxy.example.com/fetch?url=https%3A%2F%2Fcdn.site.com%2Fvideo.m3u8' -``` - -### curl fallback (default Node transport) - -When `CurlFallbackTransport` is in use and `fetch` throws (timeout, TLS -error, anti-bot rejection), the transport automatically retries the -request using `curl` via `child_process.execSync`. The fallback -synthesises a standard `Response`-shaped object, so callers see no -difference. Cookie persistence uses a per-client temp file. +Synchronous. Returns rolling success/latency stats per source. --- -## URN helpers +## Value types -Every `id` flowing through the SDK is a URN of shape -`${providerId}:${rawId}`. Helpers live in `utils/urn.ts`: +### `Media` ```ts -buildUrn(providerId: string, rawId: string): Urn -parseUrn(urn: string): { providerId: string; rawId: string } -unwrapUrn(providerId: string, urn: string): string -strictUnwrapUrn(providerId: string, urn: string): string // throws on mismatch -isUrn(value: string, providerId?: string): boolean - -// Typed catalogue URNs — for MAL/Kitsu where the integer ID isn't -// globally unique across anime/manga. -buildTypedUrn(providerId: string, kind: 'anime' | 'manga', rawId: string | number): Urn -parseTypedUrn(providerId: string, urn: string): { kind?: 'anime' | 'manga'; rawId: string } +interface Media { + id: string; // opaque base64url token — pass to SDK methods + kind: 'anime' | 'manga'; + title: MediaTitle; + cover?: MediaCover; + banner?: string; + score?: Score; + year?: number; + season?: 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL'; + status?: 'FINISHED' | 'RELEASING' | 'NOT_YET_RELEASED' | 'CANCELLED' | 'HIATUS'; + format?: 'TV' | 'MOVIE' | 'OVA' | 'ONA' | 'SPECIAL' | 'MANGA' | 'NOVEL'; + episodeCount?: number; + chapterCount?: number; + description?: string; + catalogues: string[]; // which catalogues contributed + playbackSources: string[]; // which playback sources have it + mappings: { + anilist?: number; + mal?: number; + kitsu?: number; + sources?: Record; // source id → raw media id + }; +} ``` -`strictUnwrapUrn` throws on a prefix mismatch — use it in routing paths -where a wrong URN should fail loudly instead of being silently -misrouted (the server uses it on `/meta/info`). - ---- - -## BaseProvider +### `MediaTitle` ```ts -abstract class BaseProvider { - abstract readonly id: string; - abstract readonly supportedTypes: MediaCatalogType[]; - - constructor(protected http: HttpClient); - - // Public API — URN-prefixed ids in and out. - search(query: string, options?: CallOptions): Promise; - fetchContentUnits(mediaUrn: Urn, options?: CallOptions): Promise; - resolveStream( - unitUrn: Urn, - language?: ContentLanguage, - options?: CallOptions, - ): Promise; - fetchUnitTracks( - unitUrn: Urn, - language?: ContentLanguage, - options?: CallOptions, - ): Promise; - - get supportsUnitTracks(): boolean; - - /** Optional: provider-native cross-source lookup. When a provider's - * site indexes by AniList/MAL ID directly, implement this and - * MappingClient will use it before the external/fuzzy fallbacks. */ - lookupByMapping?( - mappings: IMediaMappings, - options?: CallOptions, - ): Promise; - - /** Optional: MALSync `Sites` aliases this provider corresponds to. */ - static readonly malsyncSites: readonly string[]; - - /** Optional: caps in-flight calls on this provider via FIFO semaphore. */ - readonly maxConcurrency: number; - - // Subclass surface — raw (non-URN) IDs. Subclasses implement these; - // the public methods above wrap them with URN encoding + concurrency cap. - protected abstract searchRaw( - query: string, - options?: CallOptions, - ): Promise; - protected abstract fetchContentUnitsRaw( - rawMediaId: string, - options?: CallOptions, - ): Promise; - protected abstract resolveStreamRaw( - rawUnitId: string, - language?: ContentLanguage, - options?: CallOptions, - ): Promise; - protected fetchUnitTracksRaw?( - rawUnitId: string, - language?: ContentLanguage, - options?: CallOptions, - ): Promise; +interface MediaTitle { + preferred: string; // best available: english ?? romaji ?? native + english?: string; + romaji?: string; + native?: string; } ``` ---- - -## Metadata layer - -### `IMediaMetadata` - -The full normalized metadata record returned by -`metaProvider.fetchMediaInfo`. Includes everything the catalogue ships: +### `MediaCover` ```ts -interface IMediaMetadata { - id: Urn; // e.g. 'anilist:1' - providerId: string; // 'anilist' | 'mal' | 'kitsu' - catalogType: MediaCatalogType; - title: IMediaTitle; // romaji / english / native / userPreferred - description?: string; - cover?: IMediaImage; - banner?: string; - status?: MediaStatus; - format?: MediaFormat; - episodeCount?: number; - chapterCount?: number; - durationMinutes?: number; - genres?: string[]; - tags?: string[]; - studios?: string[]; - year?: number; - season?: MediaSeason; - startDate?: string; // ISO 8601 yyyy-mm-dd - endDate?: string; - score?: number; // normalized 0–100 - trailer?: string; - isAdult?: boolean; - synonyms?: string[]; - mappings?: IMediaMappings; // cross-source: anilist / mal / kitsu / anidb / … - relations?: IMediaRelation[]; - characters?: IMediaCharacter[]; // includes voiceActors[] - staff?: IMediaStaff[]; - recommendations?: IMediaRecommendation[]; - externalLinks?: IMediaExternalLink[]; - streamingEpisodes?: IStreamingEpisode[]; // per-episode title/thumbnail +interface MediaCover { + url: string; + color?: string; // dominant color in hex (e.g. '#e4a15d') } ``` -### `BaseMetadataProvider` +### `Score` ```ts -abstract class BaseMetadataProvider { - abstract readonly id: string; // 'anilist' | 'mal' | 'kitsu' - abstract readonly supportedTypes: MediaCatalogType[]; - - search(query: string, options?: CallOptions): Promise; - fetchMediaInfo(metaUrn: Urn, options?: CallOptions): Promise; - fetchContentUnits( - metaUrn: Urn, - contentProvider: BaseProvider, - options?: CallOptions, - ): Promise; - resolveStream( - metaUrn: Urn, - episodeNumber: number, - contentProvider: BaseProvider, - language?: ContentLanguage, - options?: CallOptions, - ): Promise; - fetchUnitTracks( - metaUrn: Urn, - episodeNumber: number, - contentProvider: BaseProvider, - language?: ContentLanguage, - options?: CallOptions, - ): Promise; - browse(kind: BrowseKind, options?: BrowseOptions): Promise; - supportsBrowseKind(kind: BrowseKind): boolean; - - /** Walks PREQUEL relations to compute a season offset for multi-season - * titles where the meta record's per-season numbering misaligns with - * the content provider's continuous list. */ - computeAbsoluteEpisodeOffset(metaUrn: Urn, options?: CallOptions): Promise; +interface Score { + value: number; // e.g. 87 + scale: number; // e.g. 100 } - -type BrowseKind = 'trending' | 'popular' | 'seasonal' | 'top'; +// Display as: (score.value / score.scale * 10).toFixed(1) → "8.7" ``` -Concrete providers: `AnilistMeta`, `MalMeta`, `KitsuMeta`. - -### `MappingClient` - -Resolves a metadata record onto a content provider's raw media ID via a -four-step waterfall: SdkCache → `provider.lookupByMapping` → -MALSync/Anify/arm-server (raced in parallel) → fuzzy title search. +### `Episode` ```ts -class MappingClient { - constructor(http: HttpClient, options?: MappingClientOptions); - - resolveProviderMediaId( - metadata: IMediaMetadata, - contentProvider: BaseProvider, - options?: CallOptions, - ): Promise; -} - -interface MappingResolution { - providerId: string; - rawMediaId: string; - matchedTitle: string; - method: 'cached' | 'provider' | 'malsync' | 'anify' | 'arm' | 'fuzzy'; - similarity?: number; +interface Episode { + id: string; // opaque — pass to sdk.stream() + mediaId: string; + number: number; + title?: string; + thumbnail?: string; + airDate?: string; + filler?: boolean; + recap?: boolean; + languages: ('sub' | 'dub' | 'raw')[]; + qualities: ('1080p' | '720p' | '480p' | '360p' | 'auto')[]; + source: string; // source id that produced this entry } ``` -The fuzzy matcher uses composite similarity (Sørensen–Dice + token -Jaccard + prefix score) with year and catalogType discriminators and an -optional episode-count cross-check for borderline matches. The metadata -record is never mutated; results land in the optional `SdkCache` keyed -by `mapping:${metaProvider}:${metaNativeId}:${contentProvider}`. - ---- - -## BaseExtractor +### `Chapter` ```ts -abstract class BaseExtractor { - abstract readonly id: string; - constructor(protected http: HttpClient); - abstract extract(embedUrl: string): Promise; +interface Chapter { + id: string; // opaque — pass to sdk.pages() + mediaId: string; + number: number; + title?: string; + source: string; } ``` -Extractors return an empty array (never throw) when they cannot handle the given URL. - ---- - -## Extractors - -### `BloggerExtractor` - -Extracts `googlevideo.com` MP4 URLs from Blogger video embeds. +### `Stream` ```ts -class BloggerExtractor extends BaseExtractor { - readonly id = 'blogger'; - static matches(url: string): boolean; // true for blogger.com/video.g?token=... - extract(embedUrl: string): Promise; +interface Stream { + url: string; + origin: { host: string; url: string; proxied: boolean }; + isHls: boolean; + qualities: { label: '1080p' | '720p' | '480p' | '360p' | 'auto'; url: string }[]; + language: 'sub' | 'dub' | 'raw'; + subtitles: Subtitle[]; + headers?: Record; + adjacent: { + prev?: { id: string; number: number }; + next?: { id: string; number: number }; + }; } ``` -**Flow:** - -1. Fetch the embed page; extract `FdrFJe` (session ID) and `cfb2h` (build hash). -2. POST to `/_/BloggerVideoPlayerUi/data/batchexecute` with the `WcwnYd` RPC. -3. Parse the response: find the stream array inside the `wrb.fr` / `WcwnYd` envelope; prefer `itag=22` (720p) over `itag=18` (360p). -4. Falls back to a raw regex scan for `googlevideo.com` URLs if structured parsing yields nothing. - -Returned URLs require `{ Referer: 'https://www.blogger.com/' }` headers. - -### `Mp4UploadExtractor` +`origin.host` is the CDN hostname (never parse `url` to get it). `proxied: true` when the URL goes through the SDK's proxy endpoint. -Extracts direct MP4 URLs from `mp4upload.com` embed pages. +### `Pages` ```ts -class Mp4UploadExtractor extends BaseExtractor { - readonly id = 'mp4upload'; - static matches(url: string): boolean; // true for mp4upload.com URLs - extract(embedUrl: string): Promise; +interface Pages { + pages: { url: string; origin: { host: string }; width?: number; height?: number }[]; + adjacent: { + prev?: { id: string; number: number }; + next?: { id: string; number: number }; + }; } ``` -Parses the `player.src({ src: "https://...video.mp4" })` line from the embed HTML. Returned URLs require `{ Referer: 'https://mp4upload.com/' }`. - -### `GenericHlsExtractor` - -Best-effort extractor that scans any embed page for a `.m3u8` or `.mp4` URL. +### `Subtitle` ```ts -class GenericHlsExtractor extends BaseExtractor { - readonly id = 'generic-hls'; - extract(embedUrl: string): Promise; +interface Subtitle { + url: string; + language: string; // BCP-47 (e.g. 'en', 'pt-BR') + label: string; // human-readable ('English') + format: 'vtt' | 'srt' | 'ass'; } ``` -Prefers `.m3u8` over `.mp4`. Handles JSON-embedded URLs with escaped slashes. Cannot decrypt obfuscated players (filemoon, streamwish, ok.ru). Returns `[]` if nothing matches. - -### `VidstreamingExtractor` - -Handles vidstreaming.io / gogoplay embeds with AES-CBC decryption. +### `List` ```ts -class VidstreamingExtractor extends BaseExtractor { - readonly id = 'vidstreaming'; - extract(embedUrl: string): Promise; +interface List { + items: T[]; + nextCursor?: string; // undefined when exhausted + total?: number; } ``` -**Flow:** - -1. Fetch the embed page. Extract encryption key, IV, and decryption key from `container-N` / `videocontent-N` class names. -2. Decrypt the `data-value` attribute with AES-CBC using the encryption key. -3. Encrypt the `id` query param with AES-CBC. -4. GET `{host}/encrypt-ajax.php?{decrypted_data}&id={encrypted_id}&alias={contentId}` with `X-Requested-With: XMLHttpRequest`. -5. Decrypt the `data` field of the JSON response with AES-CBC using the decryption key. -6. Parse the resulting JSON: `source` and `source_bk` arrays each contain `{ file, label }` entries. - ---- - -## HlsUtils +### `SourceInfo` ```ts -class HlsUtils { - static rewriteManifest(manifestText: string, playlistUrl: string, httpClient: HttpClient): string; +interface SourceInfo { + id: string; + status: 'available' | 'incompatible' | 'error'; + episodeCount?: number; + successRate?: number; // rolling 0–1 from last 20 calls } ``` -Rewrites every URI in an `.m3u8` manifest to route through the `HttpClient`'s configured proxy: - -- Plain URI lines (chunks, sub-playlists) -- `URI="..."` attributes inside `#EXT-X-KEY`, `#EXT-X-MAP`, and similar tags -- Relative URIs are first resolved to absolute using `playlistUrl` - -Returns the manifest unchanged if no proxy is configured on the client. - ---- - -## DomRegistry +### `ProgressiveResult` ```ts -class DomRegistry { - static register(customParser: IDomParser): void; - static getParser(): IDomParser; - static parse(html: string): IDomElement; +interface ProgressiveResult extends AsyncIterable, PromiseLike { + cancel(): void; } ``` -Global singleton parser used by all providers that scrape HTML. The default is `BrowserDomParser`, which wraps `globalThis.DOMParser`. +--- -`linkedom` is registered automatically on import in Node environments: no setup is needed. `DomRegistry.register()` is only needed when you want to swap in a fully custom parser. +## Errors -**Custom parser:** +### `AniError` ```ts -import { DomRegistry, IDomParser, IDomElement } from 'anime-sdk'; - -class MyParser implements IDomParser { - parse(html: string): IDomElement { - /* ... */ - } +class AniError extends Error { + readonly code: AniErrorCode; + readonly source?: string; // source id, when known + readonly retryable: boolean; + readonly cause?: unknown; } - -DomRegistry.register(new MyParser()); ``` -### `IDomElement` / `IDomParser` +### `AniErrorCode` ```ts -interface IDomParser { - parse(html: string): IDomElement; -} - -interface IDomElement { - querySelector(selector: string): IDomElement | null; - querySelectorAll(selector: string): IDomElement[]; - getAttribute(name: string): string | null; - readonly textContent: string | null; - readonly outerHTML: string; - readonly innerHTML: string; -} +const AniErrorCode = { + SourceUnavailable: 'SourceUnavailable', // upstream unreachable + NoStream: 'NoStream', // no playable URL found + RegionBlocked: 'RegionBlocked', // geo-restricted content + RateLimited: 'RateLimited', // rate limit hit + NotFound: 'NotFound', // title/episode not found + Cancelled: 'Cancelled', // AbortSignal fired + BadId: 'BadId', // malformed opaque id +} as const; ``` --- -## startServer +## Server -```ts -function startServer(options: ServerOptions): http.Server; - -interface ServerOptions { - providers: BaseProvider[]; - /** Metadata providers exposed under `/meta/*`. */ - metaProviders?: BaseMetadataProvider[]; - port?: number; // default: 3000 - auth?: { token: string }; - proxy?: boolean; - /** Explicit proxy base. When omitted, derived from each request's - * Host header (+ X-Forwarded-Proto) — so the SDK works behind - * reverse proxies without configuration. */ - proxyBase?: string; - /** When set, /proxy requires an HMAC-SHA256 `sig` query parameter - * on every request. The server signs URLs it emits automatically. */ - proxySignSecret?: string; - /** Suffix-matched SSRF allowlist. Targets outside the list return 403. */ - proxyAllowedHosts?: string[]; - cache?: SdkCache; -} -``` +### `startServerV2(opts?)` -Launches a Node `http.Server`. Returns the server instance so you can call `server.close()`. All requests must be `GET`; all responses are JSON. See [HTTP Server](/docs/http-server/) for full route documentation and [Stream Proxy](/docs/proxy/) for the proxy behaviour. +```ts +import { startServerV2 } from 'anime-sdk/server'; -Routes: +function startServerV2(opts?: { + port?: number; // default: 0 (random); set PORT env var for 3030 + sdk?: Sdk; // default: createSdk() +}): http.Server; +``` -- **Content provider**: `/search`, `/content`, `/stream`, `/tracks` -- **Metadata layer**: `/meta/search`, `/meta/info`, `/meta/content`, `/meta/stream`, `/meta/tracks`, `/meta/browse` -- **Discovery**: `/health`, `/openapi.json` -- **Downloads**: `/download/video`, `/download/video/progress`, `/download/video/file`, `/download/manga/page`, `/download/manga/chapter`, `/download/manga/chapter/progress`, `/download/manga/chapter/file` -- **Proxy** (when `proxy: true`): `/proxy` +Returns an `http.Server`. `server.close()` to shut down. See [HTTP Server](/docs/http-server/) for all routes. --- ## Download utilities -Functions for saving resolved streams to disk. Requires Node 20+; `ffmpeg` on `PATH` for HLS video downloads only. - -### `downloadVideo` +### `downloadVideo(stream, outputPath, opts?)` ```ts +import { downloadVideo } from 'anime-sdk'; + function downloadVideo( - streams: IVideoPayload | IVideoPayload[], + stream: Stream, outputPath: string, - options?: DownloadVideoOptions, -): Promise; + opts?: { + onProgress?: (info: { phase: string; detail?: string }) => void; + timeoutMs?: number; // default: 300_000 (5 min) + }, +): Promise<{ outputPath: string; fileSize: number }>; ``` -Downloads an anime episode to a `.mp4` file. Accepts a single stream or an array: if an array, each candidate is tried in order until one succeeds. Throws with a combined error message if all fail. +HLS: downloads segments, muxes with `ffmpeg`. MP4: streams directly to disk. -- **HLS streams**: downloads segments, strips PNG-wrapped bytes, concatenates into a `.ts` file, then calls `ffmpeg -c copy` to mux to MP4. -- **Direct MP4**: streams to disk via `fetch`. +### `downloadMangaChapter(pages, outputPath, opts?)` ```ts -interface DownloadVideoOptions { - onProgress?: (info: { phase: string; detail?: string }) => void; - timeoutMs?: number; // default 300_000 (5 min) -} +import { downloadMangaChapter } from 'anime-sdk'; -interface DownloadVideoResult { - outputPath: string; - stream: IVideoPayload; // the candidate that succeeded - fileSize: number; // bytes -} -``` - -Progress `phase` values: `'resolving'` → `'downloading'` → `'muxing'` → `'complete'`. - -### `downloadMangaPage` - -```ts -function downloadMangaPage( - pages: IMangaPayload, - pageIndex: number, - outputDir: string, - options?: DownloadMangaPageOptions, -): Promise; -``` - -Downloads a single manga page. The filename is auto-generated as `page_` where the extension is inferred from the `Content-Type` header. - -```ts -interface DownloadMangaPageOptions { - headers?: Record; // override the headers on IMangaPayload - timeoutMs?: number; // default 30_000 -} - -interface DownloadMangaPageResult { - outputPath: string; - pageIndex: number; - fileSize: number; - contentType: string; -} -``` - -### `downloadMangaChapter` - -```ts function downloadMangaChapter( - pages: IMangaPayload, + pages: Pages, outputPath: string, - options?: DownloadMangaChapterOptions, -): Promise; -``` - -Downloads all pages and packages them as an uncompressed `.zip` archive (STORE method: images are already compressed). No external dependencies. - -```ts -interface DownloadMangaChapterOptions { - onProgress?: (info: { downloaded: number; total: number }) => void; - timeoutMs?: number; // per page; default 30_000 -} - -interface DownloadMangaChapterResult { - outputPath: string; - pageCount: number; - fileSize: number; // total ZIP size in bytes -} + opts?: { + onProgress?: (info: { downloaded: number; total: number }) => void; + timeoutMs?: number; // per page; default: 30_000 + }, +): Promise<{ outputPath: string; pageCount: number; fileSize: number }>; ``` -### HLS helpers +Packages all pages as an uncompressed `.zip` archive. -Low-level utilities exported for custom pipelines: +### `downloadMangaPage(pages, pageIndex, outputDir, opts?)` ```ts -// Parse variant playlist URLs from a master playlist -parseHlsMaster(content: string, baseUrl: string): string[] - -// Parse segment descriptors from a media playlist -parseHlsSegments(content: string, baseUrl: string): Array<{ url: string; duration: number }> - -// Infer file extension from Content-Type -detectImageExtension(contentType: string): string - -// CRC-32 checksum (used by the ZIP writer) -crc32(buf: Buffer): number - -// Build an uncompressed ZIP buffer -createZipBuffer(entries: Array<{ filename: string; data: Buffer }>): Buffer -``` - ---- - -## Subtitle utilities +import { downloadMangaPage } from 'anime-sdk'; -Exported helpers used internally by `AnimeParadiseProvider` and `startServer`; available for custom providers and self-hosted setups. - -```ts -// Normalise an arbitrary array of {src|url, label, type|format} entries into -// ISubtitleTrack[]. Non-http(s) entries (Drive IDs, etc.) are dropped. -normalizeSubtitleEntries(entries: unknown): ISubtitleTrack[] - -// Wrap a subtitle URL through the SDK's /proxy endpoint. Forces -// Content-Type: text/vtt on VTT files so browsers parse them correctly. -proxifySubtitleUrl( - proxyBase: string, - track: ISubtitleTrack, - options?: { headers?: Record; contentType?: string } -): string - -// BCP-47 inference from a human label (best-effort). -labelToBcp47(label: string): string +function downloadMangaPage( + pages: Pages, + pageIndex: number, + outputDir: string, + opts?: { timeoutMs?: number }, +): Promise<{ outputPath: string; fileSize: number; contentType: string }>; ``` --- -## Crypto utilities - -Exported for use in custom providers. +## Internal utilities (not exported from main entry) -```ts -// AES-CBC decrypt: returns plaintext string -aesDecrypt(ciphertextBase64: string, keyStr: string, ivStr: string): Promise - -// AES-CBC encrypt: returns base64 string -aesEncrypt(plaintext: string, keyStr: string, ivStr: string): Promise - -// SHA-256 hash -sha256(text: string): Promise - -// AES-CTR decrypt (used by AllmangaProvider) -aesDecryptCtr(ciphertext: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise -``` +These are still exported for custom integrations but are not part of the primary API: -All functions use `globalThis.crypto.subtle` (available in Node 20+ and all modern browsers). +- `HlsUtils.rewriteManifest(manifestText, playlistUrl, httpClient)` — rewrites `.m3u8` URIs through a proxy +- `DomRegistry.parse(html)` — singleton HTML parser backed by `linkedom` +- `normalizeSubtitleEntries(entries)` — normalize arbitrary subtitle shapes to `ISubtitleTrack[]` +- `proxifySubtitleUrl(proxyBase, track, opts?)` — wrap a subtitle URL through `/proxy` diff --git a/website/src/content/docs/docs/contributing.mdx b/website/src/content/docs/docs/contributing.mdx index cb524f8..1e1b613 100644 --- a/website/src/content/docs/docs/contributing.mdx +++ b/website/src/content/docs/docs/contributing.mdx @@ -1,204 +1,203 @@ --- title: Contributing -description: How to add a new provider or extractor, and what the live E2E test suite expects. +description: How to add a new source or extractor, and what the live E2E test suite expects. --- ## What we want -### New providers +### New sources The highest-value contribution. A good target is a site that: - Has a public search endpoint (no login required) - Hosts direct HLS or MP4 streams, possibly behind a simple embed layer -- Covers a language or catalogue not already served by the three existing providers +- Covers a language or catalogue not already served by the existing sources -### Bug fixes for existing providers +### Bug fixes for existing sources -Site layouts change. If a provider's scraping logic breaks because the upstream site updated its HTML, a targeted fix with a passing E2E test is always welcome. Open an issue first if the fix is large. +Site layouts change. If a source's scraping logic breaks because the upstream site updated its HTML or API, a targeted fix with a passing E2E test is always welcome. ### New extractors -Add one when the embed format is genuinely novel: not handled by `GenericHlsExtractor`, `Mp4UploadExtractor`, `BloggerExtractor`, or `VidstreamingExtractor`. Common cases: new obfuscation schemes (encrypted player config, custom XOR), embed hosts that require a multi-step auth flow, or platforms that gate streams behind a non-standard API. - -### Metadata provider improvements - -Extending `MalMeta` / `KitsuMeta` toward AniList's parity (characters with voice actors, staff, recommendations), AniList batch fetching (`Page.media(id_in: [...])`), or richer Anify/arm-server integration. Any new field must be live-tested against the real upstream. - -### Mapping-client improvements - -Better fuzzy-match heuristics for tricky titles (multi-season shows, abbreviations, romanization variants); provider-specific `lookupByMapping` implementations for sites that index by AniList/MAL ID directly; new external mapping sources. - -### Transport and HTTP server improvements - -- Proxy mode extensions (new `proxyType` variants, header-based routing) -- Better `curl` fallback diagnostics -- Custom `HttpTransport` implementations (Undici dispatcher, Workers fetch, Bun compatibility) -- HTTP server: new routes, streaming responses, per-provider auth - -### Unit / pure-logic tests - -Edge cases in `HlsUtils.rewriteManifest`, extractor HTML fixtures, language inference, URN helpers, similarity scoring, rate limiter, retry policy. These run in CI without a network. +Add one when the embed format isn't handled by `GenericHlsExtractor`, `Mp4UploadExtractor`, `BloggerExtractor`, or `VidstreamingExtractor`. --- ## What's out of scope -| Area | Why | -| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Browser / frontend support** | The SDK depends on `child_process` (curl fallback), Node-specific `crypto.subtle` usage, and shell-out for E2E tests. Browser support would require a fundamentally different transport layer and likely wouldn't work due to CORS constraints. | -| **UI components or players** | anime-sdk is a headless library. Rendering is out of scope. | -| **Login-gated or paywall sites** | anime-sdk is for publicly accessible streams. Sites that require accounts are out of scope. | -| **Caching or rate-limiting layers** | Those belong in the application layer, not the SDK. | -| **CLI wrappers or download scripts** | Out of scope: use the HTTP server or call the SDK directly. | -| **Mocked E2E tests** | All E2E tests must hit live sites. A provider test that passes with mocks but fails against the real site is worse than no test. | -| **Tests with graceful skip-on-unreachable** | A skipped test is a lie. A test must pass for real or be deleted. See `CLAUDE.md` for the non-negotiable testing rules. | +| Area | Why | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| **Browser / frontend support** | The SDK depends on `child_process`, Node-specific `crypto.subtle`, and shell-out for E2E tests. | +| **UI components or players** | anime-sdk is a headless library. | +| **Login-gated or paywall sites** | Only publicly accessible streams. | +| **Mocked E2E tests** | All E2E tests must hit live sites. A test that passes with mocks but fails for real is worthless. | +| **Tests with graceful skip-on-unreachable** | A skipped test is a lie. Pass for real or delete it. | --- -## Overview +## Architecture overview -anime-sdk has four layers: +- **`src/internal/`**: `HttpClient`, `DomRegistry`, `HlsUtils`, `MappingClient`, `encodeId`/`decodeId`. Rarely changes for new sources. +- **`src/extractors/`**: stateless embed parsers. Add one when an embed platform is genuinely novel. +- **`src/sources/`**: site-specific adapters. One `Source` interface, optional capability methods. -- **Transport** (`src/transport/`): `HttpClient`, `HttpTransport`, `RateLimiter`, `withRetry`, `DomRegistry`, `HlsUtils`. Rarely needs changes for new providers. -- **Extractors** (`src/extractors/`): stateless embed parsers. Add one when an embed platform is genuinely novel. -- **Providers** (`src/providers/`): site-specific adapters. This is where most content-provider contributions live. -- **Metadata** (`src/meta/`): catalogue-level data (AniList / MAL / Kitsu) plus `MappingClient` for cross-source mapping. Touch this layer for new catalogues or mapping heuristics. - -All relative imports in `src/` must include the `.js` extension (`import { X } from './foo.js'`). TypeScript is configured with `module: NodeNext`. +All relative imports in `src/` must include the `.js` extension (`import { X } from './foo.js'`). --- -## Adding a provider +## Adding a source -### 1. Create `src/providers/MyProvider.ts` +### 1. Create `src/sources/mysource.ts` ```ts -import { BaseProvider } from './BaseProvider.js'; -import { HttpClient } from '../transport/http.js'; -import { DomRegistry } from '../transport/dom.js'; -import type { - IMediaSearchResult, - IContentUnit, - ResolvedMediaStream, - MediaCatalogType, - ContentLanguage, -} from '../types/index.js'; - -export interface MyProviderOptions { - baseUrl?: string; -} +import { HttpClient } from '../internal/http.js'; +import { DomRegistry } from '../internal/dom.js'; +import { encodeId, decodeId } from '../internal/id.js'; +import type { Media, Episode, Stream, List } from '../types.js'; +import type { Source, SourceCallOpts } from './base.js'; + +export class MySource implements Source { + readonly id = 'mysource'; + readonly kinds = ['anime'] as const; + readonly caps = { search: true, episodes: true, stream: true } as const; -export class MyProvider extends BaseProvider { - public readonly id = 'myprovider'; - public readonly supportedTypes: MediaCatalogType[] = ['ANIME']; private baseUrl = 'https://example-anime-site.com'; - constructor(http: HttpClient, options: MyProviderOptions = {}) { - super(http); - if (options.baseUrl) this.baseUrl = options.baseUrl; - } + constructor(private http: HttpClient) {} - public async search(query: string): Promise { - const res = await this.http.get(`${this.baseUrl}/search?q=${encodeURIComponent(query)}`); + async search(query: string, _kind: 'anime' | 'manga', opts: SourceCallOpts): Promise { + const res = await this.http.get(`${this.baseUrl}/search?q=${encodeURIComponent(query)}`, { + signal: opts.signal, + }); if (res.status !== 200) throw new Error(`Search failed: ${res.status}`); - - const html = await res.text(); - const doc = DomRegistry.parse(html); - - return doc.querySelectorAll('.anime-card').map((card) => ({ - id: card.querySelector('a')?.getAttribute('href') ?? '', - title: (card.querySelector('h3')?.textContent ?? '').trim(), - catalogType: 'ANIME', - providerId: this.id, - })); + const doc = DomRegistry.parse(await res.text()); + return doc.querySelectorAll('.anime-card').map( + (card): Media => ({ + id: encodeId({ + t: 'media', + s: this.id, + r: card.querySelector('a')?.getAttribute('href') ?? '', + }), + kind: 'anime', + title: { preferred: (card.querySelector('h3')?.textContent ?? '').trim() }, + catalogues: [this.id], + playbackSources: [this.id], + mappings: {}, + }), + ); } - public async fetchContentUnits(mediaId: string): Promise { - const res = await this.http.get(`${this.baseUrl}${mediaId}`); - if (res.status !== 200) throw new Error(`Failed to fetch: ${res.status}`); - - const html = await res.text(); - const doc = DomRegistry.parse(html); - - return doc.querySelectorAll('.episode-item a').map((a, i) => ({ - id: a.getAttribute('href') ?? '', - title: `Episode ${i + 1}`, - number: i + 1, - availableLanguages: ['sub'], - })); + async episodes( + mediaId: string, + opts: SourceCallOpts & { cursor?: string; limit?: number }, + ): Promise> { + const res = await this.http.get(`${this.baseUrl}${mediaId}`, { signal: opts.signal }); + if (res.status !== 200) throw new Error(`Failed: ${res.status}`); + const doc = DomRegistry.parse(await res.text()); + return { + items: doc.querySelectorAll('.episode-item a').map( + (a, i): Episode => ({ + id: encodeId({ t: 'episode', s: this.id, r: a.getAttribute('href') ?? '' }), + mediaId: encodeId({ t: 'media', s: this.id, r: mediaId }), + number: i + 1, + title: `Episode ${i + 1}`, + languages: ['sub'], + qualities: ['auto'], + source: this.id, + }), + ), + }; } - public async resolveStream( - unitId: string, - language?: ContentLanguage, - ): Promise { - const res = await this.http.get(`${this.baseUrl}${unitId}`); - if (res.status !== 200) throw new Error(`Failed to fetch: ${res.status}`); - + async stream(episodeId: string, opts: SourceCallOpts): Promise { + const { r: rawUnit } = decodeId(episodeId); + const res = await this.http.get(`${this.baseUrl}${rawUnit}`, { signal: opts.signal }); + if (res.status !== 200) throw new Error(`Failed: ${res.status}`); const html = await res.text(); const embedUrl = html.match(/src=["'](https:\/\/embed\.example\.com\/[^"']+)["']/)?.[1]; if (!embedUrl) throw new Error('No embed URL found'); - // Use an existing extractor: const { GenericHlsExtractor } = await import('../extractors/GenericHlsExtractor.js'); const extractor = new GenericHlsExtractor(this.http); - const streams = await extractor.extract(embedUrl); - if (streams.length === 0) throw new Error('No streams extracted'); - - return { type: 'video', streams }; + const payloads = await extractor.extract(embedUrl); + if (payloads.length === 0) throw new Error('No streams extracted'); + + const p = payloads[0]; + let host = ''; + try { + host = new URL(p.sourceUrl).hostname; + } catch {} + return { + url: p.sourceUrl, + origin: { host, url: p.sourceUrl, proxied: false }, + isHls: p.isHLS, + qualities: payloads.map((q) => ({ label: q.quality, url: q.sourceUrl })), + language: 'sub', + subtitles: [], + headers: p.headers, + adjacent: {}, + }; } } ``` -### 2. Export from `src/index.ts` +### 2. Register in `src/sdk.ts` + +Add to the `buildSources()` array: ```ts -export * from './providers/MyProvider.js'; +import { MySource } from './sources/mysource.js'; + +function buildSources(http: HttpClient, enabled: ReadonlyArray) { + const all = [ + // ...existing sources... + new MySource(http), + ]; + return all.filter((s) => set.has(s.id)); +} ``` +And add the id to `ALL_SOURCE_IDS`. + ### 3. Add a live E2E test -Create `tests/e2e/myprovider.test.ts`. The test must resolve a real stream and pass it to `captureStreamScreenshot`: no mocking allowed. The function walks candidates in order and extracts a frame with ffmpeg. +Create `tests/e2e/mysource.test.ts`. No mocking allowed. ```ts import { describe, it, expect } from 'vitest'; -import { HttpClient, MyProvider } from '../../src/index.js'; -import { captureStreamScreenshot } from './screenshotHelper.js'; - -describe('MyProvider', () => { - it( - 'resolves a stream and screenshots it', - async () => { - const provider = new MyProvider(new HttpClient()); - - const shows = await provider.search('Frieren'); - expect(shows.length).toBeGreaterThan(0); - - const eps = await provider.fetchContentUnits(shows[0].id); - expect(eps.length).toBeGreaterThan(0); - - const result = await provider.resolveStream(eps[0].id); - expect(result.type).toBe('video'); - if (result.type !== 'video') return; - - // captureStreamScreenshot(providerId, streams): accepts IVideoPayload | IVideoPayload[] - const { outputPath } = await captureStreamScreenshot('myprovider', result.streams); - const stat = (await import('fs')).statSync(outputPath); - expect(stat.size).toBeGreaterThan(1024); - }, - { timeout: 90_000 }, - ); +import { HttpClient } from '../../src/internal/http.js'; +import { MySource } from '../../src/sources/mysource.js'; +import { decodeId } from '../../src/internal/id.js'; +import { captureStreamScreenshot, streamToPayload } from './screenshotHelper.js'; + +describe('MySource E2E', () => { + it('searches, fetches episodes, resolves a stream, and screenshots it', async () => { + const http = new HttpClient({ timeoutMs: 25000 }); + const source = new MySource(http); + + const results = await source.search('Frieren', 'anime', {}); + expect(results.length).toBeGreaterThan(0); + + const decoded = decodeId(results[0].id); + const list = await source.episodes(decoded.r, {}); + expect(list.items.length).toBeGreaterThan(0); + + const stream = await source.stream(list.items[0].id, {}); + expect(stream.url).toBeTruthy(); + + const result = await captureStreamScreenshot('mysource', streamToPayload(stream)); + expect(result.outputPath).toMatch(/screenshot_mysource\.png$/); + }, 90000); }); ``` -`captureStreamScreenshot` returns `{ outputPath, stream, attemptedCount }`. It writes to `scratch/screenshots/screenshot_myprovider.png`. +`captureStreamScreenshot` writes to `scratch/screenshots/screenshot_mysource.png` and asserts the file is > 1 KB. --- ## Adding an extractor -Add an extractor when the embed format isn't handled by `GenericHlsExtractor`, `Mp4UploadExtractor`, `BloggerExtractor`, or `VidstreamingExtractor`. +Add one only when the embed format isn't handled by the four existing extractors. ### 1. Create `src/extractors/MyExtractor.ts` @@ -209,7 +208,6 @@ import type { IVideoPayload } from '../types/index.js'; export class MyExtractor extends BaseExtractor { public readonly id = 'myextractor'; - // Optional: static URL matcher so providers can guard before calling extract() static matches(url: string): boolean { return /(?:^|\.)myembedhost\.com\//i.test(url); } @@ -218,35 +216,21 @@ export class MyExtractor extends BaseExtractor { const res = await this.http.get(embedUrl, { headers: { Referer: 'https://referring-site.com/' }, }); - if (res.status !== 200) return []; // return [] on failure, never throw + if (res.status !== 200) return []; // always return [], never throw - const html = await res.text(); - const match = html.match(/"file"\s*:\s*"(https?:\/\/[^"]+\.m3u8)"/); + const match = (await res.text()).match(/"file"\s*:\s*"(https?:\/\/[^"]+\.m3u8)"/); if (!match) return []; - return [ - { - sourceUrl: match[1], - isHLS: true, - quality: 'auto', - headers: { Referer: embedUrl }, - }, - ]; + return [{ sourceUrl: match[1], isHLS: true, quality: 'auto', headers: { Referer: embedUrl } }]; } } ``` **Rules:** -- Always return `[]` when the extractor can't handle a URL: never throw. -- Stay stateless: extractors are constructed fresh by providers per-request. -- Include `Referer` and `User-Agent` headers in returned payloads when the CDN requires them. - -### 2. Export from `src/index.ts` - -```ts -export * from './extractors/MyExtractor.js'; -``` +- Return `[]` on failure — never throw. +- Stay stateless. +- Include `Referer`/`User-Agent` in returned payloads when the CDN requires them. --- @@ -259,34 +243,25 @@ npm run test:run # Just E2E tests npx vitest run tests/e2e -# Single provider -npx vitest run tests/e2e/myprovider.test.ts +# Single source +npx vitest run tests/e2e/mysource.test.ts -# Single test by name pattern +# Single test by name npx vitest run -t "resolves a stream" ``` -E2E tests have a 90-second timeout. They require an internet connection. Screenshots land in `scratch/screenshots/` (gitignored). - -**Build:** - -```sh -npm run build # tsc → dist/ (ESM, NodeNext) -``` - -`tests/`, `references/`, and `dist/` are excluded from the TypeScript build. +E2E tests have a 90-second timeout and require an internet connection. --- -## Checklist for a new provider +## Checklist for a new source -- [ ] Extends `BaseProvider`, sets `id` and `supportedTypes` -- [ ] Accepts `HttpClient` in constructor; optionally accepts provider-specific options via an interface -- [ ] `search` returns `IMediaSearchResult[]` with `id`, `title`, `catalogType`, `providerId` -- [ ] `fetchContentUnits` returns `IContentUnit[]` sorted by `number`; each unit sets `availableLanguages` (a non-empty subset of `ContentLanguage`) -- [ ] `resolveStream` accepts a `language` parameter, returns `ResolvedMediaStream`: at minimum `{ type: 'video', streams: [...] }` -- [ ] If the provider has external subtitle tracks, populate `IVideoPayload.subtitles` (use `normalizeSubtitleEntries` from `utils/subtitles.js` to massage upstream shapes) -- [ ] Optional: implement `fetchUnitTracks` when the provider can describe subtitle/quality availability cheaper than a full `resolveStream` +- [ ] Implements `Source` interface from `src/sources/base.ts` +- [ ] Sets `id`, `kinds`, `caps` as `readonly` +- [ ] Uses `encodeId`/`decodeId` for all external IDs +- [ ] All async methods accept `SourceCallOpts` and forward `signal` +- [ ] `stream()` returns `Stream` with `url`, `origin`, `isHls`, `qualities`, `language`, `subtitles`, `adjacent` +- [ ] Registered in `buildSources()` in `src/sdk.ts` +- [ ] Added to `ALL_SOURCE_IDS` in `src/sdk.ts` - [ ] All imports use `.js` extension -- [ ] Exported from `src/index.ts` -- [ ] Live E2E test using `captureStreamScreenshot`: no mocking, 90s timeout +- [ ] Live E2E test using `captureStreamScreenshot` / page URL check — no mocking, 90s timeout diff --git a/website/src/content/docs/docs/download.mdx b/website/src/content/docs/docs/download.mdx index 09aa7a6..27c463f 100644 --- a/website/src/content/docs/docs/download.mdx +++ b/website/src/content/docs/docs/download.mdx @@ -7,168 +7,96 @@ The SDK ships built-in download utilities for both anime and manga. No extra dep ## Anime: `downloadVideo` -`downloadVideo` accepts one or more `IVideoPayload` candidates from `resolveStream` and saves a playable `.mp4` file. - -- **HLS streams**: the playlist is walked (master → variant → segments), each segment is downloaded and concatenated into a temporary `.ts` file, then `ffmpeg -c copy` muxes it to MP4. -- **Direct MP4 streams**: downloaded via `fetch` streaming directly to disk. - ```ts -import { HttpClient, AllmangaProvider, downloadVideo } from 'anime-sdk'; - -const client = new HttpClient(); -const provider = new AllmangaProvider(client); +import { createSdk, downloadVideo } from 'anime-sdk'; -const shows = await provider.search('Frieren'); -const eps = await provider.fetchContentUnits(shows[0].id); -const result = await provider.resolveStream(eps[0].id, 'sub'); +const sdk = createSdk(); -if (result.type === 'video') { - const download = await downloadVideo(result.streams, './episode-1.mp4', { - onProgress: ({ phase, detail }) => console.log(`[${phase}] ${detail ?? ''}`), - }); +const results = await sdk.search('Frieren', { kind: 'anime' }); +const { items: episodes } = await sdk.episodes(results[0]); +const stream = await sdk.stream(episodes[0], { language: 'sub' }); - console.log(`Saved ${download.fileSize} bytes → ${download.outputPath}`); -} +const download = await downloadVideo(stream, './episode-1.mp4', { + onProgress: ({ phase, detail }) => console.log(`[${phase}] ${detail ?? ''}`), +}); +console.log(`Saved ${download.fileSize} bytes → ${download.outputPath}`); ``` -### Fallback behaviour - -Pass the full `streams` array and `downloadVideo` tries each candidate in order until one succeeds. If all fail, it throws with a combined error message listing every failure. - -```ts -// Single candidate: throws immediately on failure -await downloadVideo(result.streams[0], './episode-1.mp4'); - -// All candidates: tries each in order -await downloadVideo(result.streams, './episode-1.mp4'); -``` +- **HLS streams**: walks master → variant → segments, concatenates into `.ts`, then `ffmpeg -c copy` muxes to MP4. +- **Direct MP4**: streams to disk via `fetch`. ### Progress reporting ```ts -await downloadVideo(streams, './episode-1.mp4', { +await downloadVideo(stream, './episode-1.mp4', { onProgress: ({ phase, detail }) => { // phase: 'resolving' | 'downloading' | 'muxing' | 'complete' - // detail: human-readable string (segment count, output path, etc.) console.log(`[${phase}] ${detail ?? ''}`); }, timeoutMs: 600_000, // 10 min; default 300_000 (5 min) }); ``` -Phases in order: - -| Phase | When it fires | -| ------------- | ---------------------------------------------------------- | -| `resolving` | Before each candidate is attempted | -| `downloading` | As HLS segments or MP4 bytes are fetched | -| `muxing` | While `ffmpeg` is converting the `.ts` concat to `.mp4` | -| `complete` | After the file is written and verified (size > 1 KB check) | - ### Requirements -- `ffmpeg` must be on your `PATH` (only needed for HLS streams; direct MP4s use `fetch` only). -- The output directory is created automatically if it doesn't exist. -- A minimum file size of 1 KB is asserted after writing; candidates that produce a smaller file are retried. - ---- - -## Manga: single page - -```ts -import { MangadexProvider, HttpClient, downloadMangaPage } from 'anime-sdk'; - -const client = new HttpClient(); -const provider = new MangadexProvider(client); - -const books = await provider.search('Frieren'); -const chapters = await provider.fetchContentUnits(books[0].id); -const result = await provider.resolveStream(chapters[0].id); - -if (result.type === 'manga') { - const page = await downloadMangaPage(result.pages, 0, './chapter-1/', { - timeoutMs: 30_000, - }); - // Saves ./chapter-1/page_001.jpg (extension is auto-detected from Content-Type) - console.log(page.outputPath, page.fileSize); -} -``` - -The filename is `page_` where `` is inferred from the `Content-Type` header (`.jpg`, `.png`, `.webp`, `.gif`, `.avif`). Pages are 1-indexed and zero-padded to three digits. +- `ffmpeg` must be on `PATH` (only for HLS; direct MP4 uses `fetch`). +- Output directory is created automatically. --- ## Manga: full chapter as ZIP ```ts -import { MangadexProvider, HttpClient, downloadMangaChapter } from 'anime-sdk'; +import { createSdk, downloadMangaChapter } from 'anime-sdk'; -const client = new HttpClient(); -const provider = new MangadexProvider(client); +const sdk = createSdk(); -const books = await provider.search('Frieren'); -const chapters = await provider.fetchContentUnits(books[0].id); -const result = await provider.resolveStream(chapters[0].id); +const results = await sdk.search('Frieren', { kind: 'manga' }); +const { items: chapters } = await sdk.chapters(results[0]); +const pages = await sdk.pages(chapters[0]); -if (result.type === 'manga') { - const zip = await downloadMangaChapter(result.pages, './chapter-1.zip', { - onProgress: ({ downloaded, total }) => { - process.stdout.write(`\r${downloaded}/${total} pages`); - }, - }); - - console.log(`\nSaved ${zip.pageCount} pages (${zip.fileSize} bytes) → ${zip.outputPath}`); -} +const zip = await downloadMangaChapter(pages, './chapter-1.zip', { + onProgress: ({ downloaded, total }) => { + process.stdout.write(`\r${downloaded}/${total} pages`); + }, +}); +console.log(`\nSaved ${zip.pageCount} pages (${zip.fileSize} bytes) → ${zip.outputPath}`); ``` -Pages are downloaded in order and stored inside the ZIP as `001.jpg`, `002.png`, etc. The archive uses STORE (no compression): images are already compressed, so deflation would only add CPU cost with no size benefit. - -The ZIP writer is self-contained with no external dependencies and implements the ZIP specification directly using Node's `Buffer` APIs. +Pages are stored inside the ZIP as `001.jpg`, `002.png`, etc. No external dependencies — the ZIP writer is self-contained. --- -## Downloading via the metadata layer - -The download helpers consume `IVideoPayload[]` / `IMangaPayload`, which is -what every `resolveStream` returns — including -`BaseMetadataProvider.resolveStream`. So you can drive a download with an -AniList URN + an episode number, letting the SDK resolve the -cross-source mapping for you: +## Manga: single page ```ts -import { HttpClient, AnilistMeta, AllmangaProvider, downloadVideo } from 'anime-sdk'; +import { downloadMangaPage } from 'anime-sdk'; -const http = new HttpClient(); -const meta = new AnilistMeta(http); -const allmanga = new AllmangaProvider(http); - -const result = await meta.resolveStream('anilist:1', 1, allmanga, 'sub'); -if (result.type === 'video') { - await downloadVideo(result.streams, './cowboy-bebop-ep1.mp4'); -} +const page = await downloadMangaPage(pages, 0, './chapter-1/', { timeoutMs: 30_000 }); +// Saves ./chapter-1/page_001.jpg (extension auto-detected from Content-Type) +console.log(page.outputPath, page.fileSize); ``` --- ## Batch download -`downloadVideo` is safe to run concurrently per episode. Use `Promise.allSettled` to continue even if individual downloads fail: +`downloadVideo` is safe to run concurrently: ```ts -const episodes = await provider.fetchContentUnits(shows[0].id); +const { items: episodes } = await sdk.episodes(media); const results = await Promise.allSettled( episodes.slice(0, 5).map(async (ep) => { - const stream = await provider.resolveStream(ep.id, 'sub'); - if (stream.type !== 'video') return; - return downloadVideo(stream.streams, `./ep-${ep.number}.mp4`, { + const stream = await sdk.stream(ep, { language: 'sub' }); + return downloadVideo(stream, `./ep-${ep.number}.mp4`, { onProgress: ({ phase }) => console.log(`ep${ep.number} [${phase}]`), }); }), ); for (const r of results) { - if (r.status === 'fulfilled') console.log('ok:', r.value?.outputPath); + if (r.status === 'fulfilled') console.log('ok:', r.value.outputPath); else console.error('failed:', r.reason); } ``` @@ -180,18 +108,16 @@ for (const r of results) { ```ts interface DownloadVideoOptions { onProgress?: (info: { phase: string; detail?: string }) => void; - timeoutMs?: number; // default 300_000 (5 min) + timeoutMs?: number; // default: 300_000 (5 min) } interface DownloadVideoResult { outputPath: string; - stream: IVideoPayload; // the candidate that succeeded - fileSize: number; // bytes + fileSize: number; } interface DownloadMangaPageOptions { - headers?: Record; // override the headers on IMangaPayload - timeoutMs?: number; // default 30_000 + timeoutMs?: number; // default: 30_000 } interface DownloadMangaPageResult { @@ -203,13 +129,13 @@ interface DownloadMangaPageResult { interface DownloadMangaChapterOptions { onProgress?: (info: { downloaded: number; total: number }) => void; - timeoutMs?: number; // per page; default 30_000 + timeoutMs?: number; // per page; default: 30_000 } interface DownloadMangaChapterResult { outputPath: string; pageCount: number; - fileSize: number; // total ZIP file size in bytes + fileSize: number; } ``` @@ -217,10 +143,8 @@ interface DownloadMangaChapterResult { ## Low-level helpers -These are exported for custom download pipelines that need to work with HLS playlists directly. - ```ts -// Parse variant URLs from an HLS master playlist (lowest → highest quality order) +// Parse variant URLs from an HLS master playlist parseHlsMaster(content: string, baseUrl: string): string[] // Parse segment URLs + durations from an HLS media playlist @@ -229,9 +153,6 @@ parseHlsSegments(content: string, baseUrl: string): Array<{ url: string; duratio // Infer image extension from a Content-Type header detectImageExtension(contentType: string): '.jpg' | '.png' | '.webp' | '.gif' | '.bmp' | '.avif' -// Compute CRC-32 for a Buffer (used by the ZIP writer) -crc32(buf: Buffer): number - -// Create an uncompressed ZIP buffer from an array of { filename, data } entries +// Create an uncompressed ZIP buffer from { filename, data } entries createZipBuffer(entries: Array<{ filename: string; data: Buffer }>): Buffer ``` diff --git a/website/src/content/docs/docs/http-server.mdx b/website/src/content/docs/docs/http-server.mdx index 25e0956..9ef01d7 100644 --- a/website/src/content/docs/docs/http-server.mdx +++ b/website/src/content/docs/docs/http-server.mdx @@ -3,363 +3,236 @@ title: HTTP Server description: Run anime-sdk as a standalone HTTP server and call it from any language over HTTP. --- -`startServer` launches a Node `http.Server` that exposes the SDK's operations as HTTP routes: content-provider routes (`/search`, `/content`, `/stream`, `/tracks`), metadata routes (`/meta/search`, `/meta/info`, `/meta/content`, `/meta/stream`, `/meta/tracks`, `/meta/browse`), discovery (`/health`, `/openapi.json`), and an optional `/proxy`. Pass the providers and metadata providers you want to expose, plus optional flags for the proxy, a bearer token, and a cache. +The bundled server exposes every SDK verb as an HTTP route. Zero config to start: -## Starting the server +```sh +npx anime-sdk # → listening on http://localhost:3030 +PORT=8080 npx anime-sdk +SOURCES_DISABLED=goyabu npx anime-sdk +``` + +Or programmatically: ```ts -import { - HttpClient, - AllmangaProvider, - GogoanimeProvider, - AnilistMeta, - MalMeta, - startServer, -} from 'anime-sdk'; - -const http = new HttpClient(); - -const server = startServer({ - providers: [new AllmangaProvider(http), new GogoanimeProvider(http)], - metaProviders: [new AnilistMeta(http), new MalMeta(http)], - port: 3000, - proxy: true, - proxySignSecret: process.env.PROXY_SECRET, // HMAC-sign /proxy URLs (recommended in prod) - proxyAllowedHosts: ['wixstatic.com', 'allanime.day'], // SSRF allowlist (optional) -}); +import { startServerV2 } from 'anime-sdk/server'; +import { createSdk } from 'anime-sdk'; -// server is an http.Server — you can call server.close() to shut it down +// Zero config — SDK auto-constructed +await startServerV2({ port: 3030 }); + +// With a custom SDK instance: +const store = new Map(); +await startServerV2({ + port: 3030, + sdk: createSdk({ + cache: { get: (k) => store.get(k), set: (k, v) => store.set(k, v) }, + proxy: { signSecret: process.env.PROXY_SECRET }, + }), +}); ``` -The server logs `anime-sdk server listening on http://localhost:3000` when ready. +`startServerV2` returns an `http.Server` — call `server.close()` to shut it down. ## Routes -All routes are `GET` only. Errors return `{ error: string }` with the appropriate HTTP status. +All routes are `GET`. Errors return `{ error: string }` with the appropriate HTTP status. -### `GET /search` +### `GET /search?q=…&kind=anime` -Search for shows or manga. +Search for anime or manga. -| Param | Required | Description | -| ---------- | -------- | -------------------------------------------------------------------------------------------- | -| `q` | ✓ | Search query string | -| `provider` | ✓ | Provider ID: `allmanga`, `gogoanime`, `anikoto`, `mangadex`, `weebcentral`, or `mangapill` … | +| Param | Required | Values | +| ------ | -------- | ------------------ | +| `q` | ✓ | search query | +| `kind` | | `anime` \| `manga` | ```sh -curl 'localhost:3000/search?q=Frieren&provider=allmanga' +curl 'localhost:3030/search?q=Frieren&kind=anime' ``` -Response: `IMediaSearchResult[]` +Response: `Media[]` ```json [ { - "id": "frieren-beyond-journeys-end", - "title": "Frieren: Beyond Journey's End", - "thumbnailUrl": "https://...", - "catalogType": "ANIME", - "providerId": "allmanga", - "availableLanguages": ["sub", "dub"] + "id": "eyJ2IjoxLCJ0IjoibWVkaWEiLCJzIjoiYW5pbGlzdCIsInIiOiIxNTQ1ODcifQ", + "kind": "anime", + "title": { + "preferred": "Frieren: Beyond Journey's End", + "english": "Frieren: Beyond Journey's End" + }, + "cover": { "url": "https://s4.anilist.co/file/anilistcdn/...", "color": "#e4a15d" }, + "score": { "value": 90, "scale": 100 }, + "year": 2023, + "catalogues": ["anilist"], + "playbackSources": [], + "mappings": { "anilist": 154587, "mal": 52991 } } ] ``` -### `GET /content` +### `GET /media/:id` -List episodes for a show or chapters for a manga. One call returns the unified, language-agnostic list — each unit advertises its translations. - -| Param | Required | Description | -| ---------- | -------- | ------------------------- | -| `mediaId` | ✓ | `id` from a search result | -| `provider` | ✓ | Provider ID | +Full info for a single media record. `:id` is the opaque `id` from a search result. ```sh -curl 'localhost:3000/content?mediaId=frieren-beyond-journeys-end&provider=allmanga' -``` - -Response: `IContentUnit[]` - -```json -[ - { - "id": "frieren-beyond-journeys-end/1", - "title": "Episode 1", - "number": 1, - "availableLanguages": ["sub", "dub"] - } -] +curl 'localhost:3030/media/eyJ2IjoxLCJ0IjoibWVkaWEiLCJzIjoiYW5pbGlzdCIsInIiOiIxNTQ1ODcifQ' ``` -### `GET /stream` +Response: `Media` -Resolve a direct stream URL for an episode or image URLs for a manga chapter. Pick the translation here (if applicable). +### `GET /media/:id/episodes?cursor=…` -| Param | Required | Description | -| ---------- | -------- | -------------------------- | -| `unitId` | ✓ | `id` from a content result | -| `provider` | ✓ | Provider ID | -| `language` | | `sub` \| `dub` \| `raw` | +List episodes. Supports pagination via `cursor`. ```sh -curl 'localhost:3000/stream?unitId=frieren-beyond-journeys-end%2F1&provider=allmanga&language=dub' +curl 'localhost:3030/media/MEDIA_ID/episodes' ``` -Response: `ResolvedMediaStream` - -**Anime response:** +Response: `List` — `{ items: Episode[], nextCursor?: string }` ```json { - "type": "video", - "streams": [ + "items": [ { - "sourceUrl": "https://a4.mp4upload.com:183/d/.../video.mp4", - "isHLS": false, - "quality": "auto", - "language": "dub", - "headers": { "Referer": "https://mp4upload.com/" }, - "subtitles": [ - { - "url": "https://cdn.example.com/subs/en.vtt", - "language": "en", - "label": "English", - "format": "vtt" - } - ] + "id": "eyJ2IjoxLCJ0IjoiZXBpc29kZSIsInMiOiJtZWdhcGxheSIsInIiOiIxNTQ1ODc6MSJ9", + "mediaId": "...", + "number": 1, + "title": "Episode 1", + "languages": ["sub", "dub"], + "qualities": ["auto"], + "source": "megaplay" } ] } ``` -**Manga response:** +### `GET /media/:id/chapters?cursor=…` -```json -{ - "type": "manga", - "pages": { - "imageUrls": ["https://...", "https://..."], - "headers": { "Referer": "https://..." } - } -} -``` - -When `proxy: true`, both `sourceUrl`, anime subtitle `url`s, and manga `imageUrls` are rewritten through `/proxy` automatically. +List chapters (manga). Same shape as episodes. -### `GET /tracks` +### `GET /media/:id/sources` -Inspect subtitle + quality availability for a unit **without** resolving the playable stream. Useful for populating a subtitle selector before the user hits play. - -| Param | Required | Description | -| ---------- | -------- | -------------------------- | -| `unitId` | ✓ | `id` from a content result | -| `provider` | ✓ | Provider ID | -| `language` | | `sub` \| `dub` \| `raw` | +Ranked list of playback sources available for this title. ```sh -curl 'localhost:3000/tracks?unitId=...&provider=animeparadise&language=sub' +curl 'localhost:3030/media/MEDIA_ID/sources' ``` -Response: `IUnitTracks` +Response: `SourceInfo[]` ```json -{ - "subtitles": [ - { - "url": "http://localhost:3000/proxy?url=...&ct=text%2Fvtt", - "label": "English", - "language": "en", - "format": "vtt" - } - ], - "qualities": ["auto"] -} -``` - -Returns **501** if the provider doesn't implement `fetchUnitTracks` — read subtitles from `/stream`'s response instead. - -## Metadata routes - -The metadata layer is exposed under `/meta/*`. Each route takes -`provider=` (e.g. `anilist`, `mal`, `kitsu`) and the -mapping to a content provider happens server-side. - -### `GET /meta/search` - -```sh -curl 'localhost:3000/meta/search?provider=anilist&q=Cowboy%20Bebop' -``` - -### `GET /meta/info` - -```sh -curl 'localhost:3000/meta/info?provider=anilist&id=anilist:1' +[ + { "id": "megaplay", "status": "available", "successRate": 0.97 }, + { "id": "allmanga", "status": "incompatible" } +] ``` -Returns the full `IMediaMetadata` record: title, description, cover, -relations (sequel/prequel), characters (with voice actors), staff, -recommendations, external links, `streamingEpisodes`, and `mappings` -(cross-source IDs). +### `GET /episode/:id/stream?language=sub&quality=auto&adjacency=walk-relations` -The server validates that the URN's prefix matches the meta provider — -hitting `/meta/info?provider=anilist&id=mal:21` returns 400. +Resolve a playable stream URL. -### `GET /meta/content` - -List episodes/chapters for a meta URN on a specific content provider. -Mapping is automatic. +| Param | Values | Default | +| ----------- | ------------------------------------- | ---------------- | +| `language` | `sub` \| `dub` \| `raw` | `sub` | +| `quality` | `1080p` \| `720p` \| `480p` \| `auto` | `auto` | +| `adjacency` | `within-media` \| `walk-relations` | `walk-relations` | ```sh -curl 'localhost:3000/meta/content?provider=anilist&id=anilist:1&contentProvider=allmanga' +curl 'localhost:3030/episode/EPISODE_ID/stream?language=dub' ``` -### `GET /meta/stream` / `GET /meta/tracks` - -Resolve a stream / tracks by metadata + episode number: +Response: `Stream` -```sh -curl 'localhost:3000/meta/stream?provider=anilist&id=anilist:1&episode=1&contentProvider=allmanga&language=sub' +```json +{ + "url": "https://cdn.example.com/ep1.m3u8", + "origin": { + "host": "cdn.example.com", + "url": "https://cdn.example.com/ep1.m3u8", + "proxied": false + }, + "isHls": true, + "qualities": [{ "label": "auto", "url": "https://cdn.example.com/ep1.m3u8" }], + "language": "dub", + "subtitles": [], + "headers": { "Referer": "https://megaplay.buzz/" }, + "adjacent": { + "prev": null, + "next": { "id": "...", "number": 2 } + } +} ``` -### `GET /meta/browse` +### `GET /chapter/:id/pages` -Trending / popular / seasonal / top from the catalogue: +Resolve manga page URLs. ```sh -curl 'localhost:3000/meta/browse?provider=anilist&kind=trending&perPage=10' -curl 'localhost:3000/meta/browse?provider=anilist&kind=seasonal&season=FALL&year=2024' +curl 'localhost:3030/chapter/CHAPTER_ID/pages' ``` -## Discovery - -`GET /health` returns a JSON summary of the server's capabilities -(registered providers, registered meta providers, whether `/proxy` is -enabled). - -`GET /openapi.json` returns an OpenAPI 3.1 spec describing every route — -feed it to Swagger UI / Redoc / a codegen tool. +Response: `Pages` -## Signing `/proxy` URLs - -Setting `proxySignSecret` requires every `/proxy?url=...` to carry a -matching `sig` HMAC-SHA256 query parameter. The signature is computed over -`url` plus optional `|h=`. The proxy rewriter automatically signs URLs -it emits, so callers using `/stream` / `/meta/stream` don't need to know -about it. - -```ts -import * as crypto from 'node:crypto'; -const sig = crypto.createHmac('sha256', secret).update(targetUrl).digest('hex'); +```json +{ + "pages": [ + { + "url": "https://uploads.mangadex.org/data/hash/page1.jpg", + "origin": { "host": "uploads.mangadex.org" } + } + ], + "adjacent": { "next": { "id": "...", "number": 2 } } +} ``` -Unsigned or invalid-signature requests return 401. - -## SSRF allowlist - -`proxyAllowedHosts: string[]` restricts `/proxy` to a suffix-matched list -of upstream hosts. Each entry covers all subdomains: - -```ts -proxyAllowedHosts: ['wixstatic.com', 'allanime.day', 'mp4upload.com']; -``` +### `GET /browse?list=trending&kind=anime` -Targets outside the list return 403. When unset, all hosts are allowed — -fine for local development, risky in production (the server otherwise -becomes an open relay). +Browse a curated list. -## Authentication +| Param | Values | +| -------- | ---------------------------------------------------------- | +| `list` | `trending` \| `popular` \| `seasonal` \| `top` | +| `kind` | `anime` \| `manga` | +| `season` | `WINTER` \| `SPRING` \| `SUMMER` \| `FALL` (seasonal only) | +| `year` | four-digit year (seasonal only) | -Lock the server behind a bearer token: +Response: `List` -```ts -startServer({ - providers: [new AllmangaProvider(new HttpClient())], - port: 3000, - auth: { token: process.env.API_TOKEN! }, -}); -``` +### `GET /health` -Clients include the token in the `Authorization` header: +Returns current source health. ```sh -curl -H 'Authorization: Bearer mysecret' 'localhost:3000/search?q=Naruto&provider=allmanga' +curl 'localhost:3030/health' ``` -Requests without a valid token receive `401 Unauthorized`. Skip `auth` entirely for local use. - -## Caching - -`startServer` accepts an optional `cache` that memoises provider calls across `/search`, `/content`, `/stream`, and `/tracks`. The contract is two methods: +Response: `SourceHealth[]` -```ts -interface SdkCache { - get(key: string): unknown | Promise; - set(key: string, value: unknown): void | Promise; -} -``` - -A plain `Map` satisfies it — bring whatever store you like (Redis, SQLite, edge KV): - -```ts -const store = new Map(); -const cache = { - get: (key) => store.get(key), - set: (key, value) => void store.set(key, value), -}; - -startServer({ - providers: [new AllmangaProvider(new HttpClient())], - port: 3000, - proxy: true, - cache, -}); +```json +[{ "id": "anilist", "successRate": 1, "avgLatencyMs": 320, "calls": 15 }] ``` -Keys are namespaced so different endpoints can have different policies: - -| Prefix | Key shape | Notes | -| ---------- | ------------------------------------- | ------------------------------------------------------------------ | -| `search:` | `search::` | Stable; safe to cache for long. | -| `content:` | `content::` | Stable; safe to cache for long. | -| `stream:` | `stream:::` | Stream URLs may carry signed expiries — apply a short TTL or skip. | -| `tracks:` | `tracks:::` | Cheap to compute when the provider supports it; cache freely. | - -`get` returns `undefined` for a miss; any other value (including `null`) is treated as a hit and served as-is. To skip caching a specific endpoint, inspect the prefix in your `set` and bail out. - -## Error responses - -| Status | Meaning | -| ------ | -------------------------------------------------------------- | -| 400 | Missing or unknown query parameter | -| 401 | Missing or invalid bearer token | -| 404 | Unknown route | -| 405 | Non-GET request | -| 500 | Provider threw an error (message is included in `error` field) | - ## Calling from other languages -The server is the bridge for any client that can't run Node directly. - **Python:** ```python import httpx -base = 'http://localhost:3000' -headers = {'Authorization': 'Bearer mysecret'} - -shows = httpx.get(f'{base}/search', params={'q': 'Frieren', 'provider': 'allmanga'}, headers=headers).json() -eps = httpx.get(f'{base}/content', params={'mediaId': shows[0]['id'], 'provider': 'allmanga'}, headers=headers).json() -data = httpx.get(f'{base}/stream', params={'unitId': eps[0]['id'], 'provider': 'allmanga'}, headers=headers).json() -url = data['streams'][0]['sourceUrl'] +base = 'http://localhost:3030' +shows = httpx.get(f'{base}/search', params={'q': 'Frieren', 'kind': 'anime'}).json() +eps = httpx.get(f'{base}/media/{shows[0]["id"]}/episodes').json() +data = httpx.get(f'{base}/episode/{eps["items"][0]["id"]}/stream', params={'language': 'sub'}).json() +print(data['url']) ``` **Swift (iOS):** ```swift -let url = URL(string: "http://localhost:3000/search?q=Frieren&provider=allmanga")! +let url = URL(string: "http://localhost:3030/search?q=Frieren&kind=anime")! let (data, _) = try await URLSession.shared.data(from: url) -let shows = try JSONDecoder().decode([MediaSearchResult].self, from: data) +let shows = try JSONDecoder().decode([MediaResult].self, from: data) ``` **Kotlin / Android:** @@ -367,9 +240,39 @@ let shows = try JSONDecoder().decode([MediaSearchResult].self, from: data) ```kotlin val client = OkHttpClient() val request = Request.Builder() - .url("http://10.0.2.2:3000/search?q=Frieren&provider=allmanga") + .url("http://10.0.2.2:3030/search?q=Frieren&kind=anime") .build() val response = client.newCall(request).execute() ``` -Note: in Android emulators, `localhost` on the host machine is reachable at `10.0.2.2`. +Note: in Android emulators, `localhost` on the host machine is `10.0.2.2`. + +## Proxy + +When `proxy` is configured in `createSdk()`, all stream URLs in `/episode/:id/stream` and page URLs in `/chapter/:id/pages` responses are rewritten to go through `/proxy`. See [Stream Proxy](/docs/proxy/). + +## Caching + +Pass a cache to `createSdk()`: + +```ts +const store = new Map(); +startServerV2({ + sdk: createSdk({ + cache: { + get: (key) => store.get(key), + set: (key, value) => store.set(key, value), + }, + }), +}); +``` + +Search results and episode lists are safe to cache indefinitely. Stream URLs carry signed expiries — use a short TTL or skip caching them. + +## Error responses + +| Status | Meaning | +| ------ | ------------------------------------------------ | +| 400 | Missing required query parameter | +| 404 | Unknown route or media/episode not found | +| 500 | Source threw an error (message in `error` field) | diff --git a/website/src/content/docs/docs/index.mdx b/website/src/content/docs/docs/index.mdx index 2514610..23d0f86 100644 --- a/website/src/content/docs/docs/index.mdx +++ b/website/src/content/docs/docs/index.mdx @@ -3,218 +3,203 @@ title: Getting Started description: Install anime-sdk and resolve your first anime stream in minutes. --- -anime-sdk is a Typescript SDK that searches anime/manga catalogues (AniList, MAL, Kitsu), resolves direct video stream URLs and manga page URLs across multiple content providers, and lets you swap content providers without changing call sites. - -It has four layers: HTTP transport (with per-host rate limiting, retry, AbortSignal, and a pluggable curl fallback), extractors (embed parsers), content providers (site-specific scraping logic), and metadata providers (catalogue-level data with cross-source mapping). Everything connects through `HttpClient`. +anime-sdk is a TypeScript SDK that searches anime and manga across 12 sources and resolves direct playable stream URLs and manga page URLs. One import, nine methods, plain POJO types. ## Requirements -- **Node 20+** or **Bun**: native `fetch` and `crypto.subtle` are required -- **ffmpeg**: only needed to run the live E2E test suite for anime streams +- **Node 20+** or **Bun**: native `fetch` and `crypto.subtle` required +- **ffmpeg**: only needed for the live E2E test suite ## Install ```sh npm install anime-sdk -# -- or -- -pnpm add anime-sdk -# -- or -- -bun add anime-sdk ``` ## How it works -Every provider exposes the same three methods: `search` → `fetchContentUnits` → `resolveStream`. +`createSdk()` returns an object with nine methods. Three calls get you a playable URL: ```ts -import { HttpClient, GogoanimeProvider, MangadexProvider } from 'anime-sdk'; +import { createSdk } from 'anime-sdk'; -const client = new HttpClient({ timeoutMs: 10_000 }); +const sdk = createSdk(); // zero config — all 12 sources -// Anime example -const anime = new GogoanimeProvider(client); -const shows = await anime.search('Frieren'); -const eps = await anime.fetchContentUnits(shows[0].id); -const stream = await anime.resolveStream(eps[0].id, 'sub'); +// 1. Search +const results = await sdk.search('Frieren', { kind: 'anime' }); -// Manga example -const manga = new MangadexProvider(client); -const books = await manga.search('Frieren'); -const chapters = await manga.fetchContentUnits(books[0].id); -const pages = await manga.resolveStream(chapters[0].id); -``` +// 2. Episodes +const { items: episodes } = await sdk.episodes(results[0]); -## The resolved stream shape +// 3. Stream +const stream = await sdk.stream(episodes[0], { language: 'sub' }); +console.log(stream.url); // playable URL +console.log(stream.origin.host); // CDN hostname +console.log(stream.adjacent.next); // next episode { id, number } +``` -`resolveStream` returns `ResolvedMediaStream`: a discriminated union on `type`: +Manga works the same way — no language argument: ```ts -type ResolvedMediaStream = - | { type: 'video'; streams: IVideoPayload[] } - | { type: 'manga'; pages: IMangaPayload }; +const manga = await sdk.search('Chainsaw Man', { kind: 'manga' }); +const { items: chapters } = await sdk.chapters(manga[0]); +const pages = await sdk.pages(chapters[0]); +console.log(pages.pages[0].url); // image URL for page 1 ``` -Manga providers return `type: 'manga'`, while anime providers return `type: 'video'`. Always check `result.type` before accessing the payload. +## Value types -Each `IMangaPayload` carries: +All return values are plain TypeScript interfaces — `JSON.stringify`-safe, safe for React state, Zustand, Redux, React Query. No classes, no `instanceof` checks. ```ts -interface IMangaPayload { - imageUrls: string[]; // high-resolution image URLs for each page - headers?: Record; // Referer required by some sites +interface Media { + id: string; // opaque — pass back to SDK methods + kind: 'anime' | 'manga'; + title: { preferred: string; english?: string; romaji?: string; native?: string }; + cover?: { url: string; color?: string }; + score?: { value: number; scale: number }; // { value: 87, scale: 100 } → 8.7 + year?: number; + mappings: { anilist?: number; mal?: number; kitsu?: number }; } -``` - -Each `IVideoPayload` in the streams array has: -```ts -interface IVideoPayload { - sourceUrl: string; // direct URL or HLS manifest - isHLS: boolean; // true → feed to an HLS player - quality: '1080p' | '720p' | '480p' | '360p' | 'auto'; - language?: ContentLanguage; - headers?: Record; // Referer/User-Agent required by some CDNs - subtitles?: ISubtitleTrack[]; // external VTT tracks, when the provider has them +interface Episode { + id: string; // opaque + mediaId: string; + number: number; + title?: string; + languages: ('sub' | 'dub' | 'raw')[]; + qualities: ('1080p' | '720p' | '480p' | '360p' | 'auto')[]; + source: string; } -interface ISubtitleTrack { +interface Stream { url: string; - language: string; // BCP-47 (e.g. 'en', 'pt-BR') - label: string; // human-readable ("English", "Português") - format?: 'vtt' | 'srt' | 'ass'; + origin: { host: string; url: string; proxied: boolean }; + isHls: boolean; + qualities: { label: string; url: string }[]; + language: 'sub' | 'dub' | 'raw'; + subtitles: { url: string; language: string; label: string; format: 'vtt' | 'srt' | 'ass' }[]; + headers?: Record; + adjacent: { + prev?: { id: string; number: number }; + next?: { id: string; number: number }; + }; } ``` -## Sub / dub / raw - -`fetchContentUnits` returns one unified episode list: each `IContentUnit` advertises its translations via `availableLanguages: ContentLanguage[]`. The translation is picked at `resolveStream` time: - -```ts -type ContentLanguage = 'sub' | 'dub' | 'raw'; -``` +## Browse ```ts -const eps = await provider.fetchContentUnits(shows[0].id); -const ep = eps[0]; // ep.availableLanguages tells you what's playable -const result = await provider.resolveStream(ep.id, 'dub'); +const trending = await sdk.browse({ list: 'trending', kind: 'anime' }); +// → List = { items: Media[], nextCursor?: string } ``` -Providers that don't support the requested language fall back to `'sub'`. +Lists: `'trending'` | `'popular'` | `'seasonal'` | `'top'`. Seasonal requires `season` + `year`. -## Inspecting tracks without resolving the stream +## Which source is used? -Some providers expose a cheap metadata endpoint so a UI can show the subtitle/quality selector before playback. Implement (or call) `fetchUnitTracks` when available: +The SDK picks the best available source automatically, ranked by success rate. You don't choose — but you can inspect: ```ts -if (provider.fetchUnitTracks) { - const { subtitles, qualities } = await provider.fetchUnitTracks(ep.id, 'sub'); - // subtitles: ISubtitleTrack[], qualities: IVideoPayload['quality'][] -} +const sources = await sdk.sources(results[0]); +// → [{ id: 'megaplay', status: 'available', successRate: 0.97 }, ...] ``` -Providers without a cheap path leave the method undefined: read `IVideoPayload.subtitles` straight off the resolved stream instead. +To target a specific source, pass `sources: ['allmanga']` to `createSdk()`. -## Using the resolved URL +## Cancellation -Pass `sourceUrl` directly to any HLS player or downloader. Many CDNs require the `headers` object to be forwarded: +Every method accepts `{ signal?: AbortSignal }`: ```ts -// hls.js -const hls = new Hls(); -hls.loadSource(stream.sourceUrl); -// hls.js doesn't support custom headers on fetch: serve through your HTTP server instead - -// ffmpeg -// ffmpeg -i "url" -headers "Referer: ..." output.mp4 - -// React Native / Video -` inside `h3.nv-anime-title` provides the ID and title; `` provides the thumbnail. -2. **Episodes**: GET the watch page. Parses `article.nv-info-episode-item` elements. Each `` has an `href` (the content unit ID); the episode number is extracted from the `ep-N` segment of the path. -3. **Stream resolution**: GET the episode page. Parses `button.nv-server-btn` elements to collect embed URLs from `data-video` attributes. Each URL is passed sequentially to `GenericHlsExtractor`, which fetches the embed page and scans it for a `.m3u8` URL. Returns on the first successful extraction. - -A User-Agent header (`Chrome/120`) is set automatically on the `HttpClient` if one isn't already configured: anineko.to rejects requests without one. - ## Notes -- `resolveStream` runs embed URLs through `GenericHlsExtractor` sequentially and returns on the first successful extraction, so resolution is fast (~0.5s) even when multiple server buttons are present. Falls back to raw embed URLs if nothing resolves. -- If anineko.to changes domain, pass the new URL via `options.baseUrl`. +- Sub only. +- Stream resolution: scrapes `button.nv-server-btn[data-video]` from the episode page, tries each server with `GenericHlsExtractor`, stops on first success. +- Dub variants have `-dub` in the show slug — the SDK handles this automatically. diff --git a/website/src/content/docs/docs/providers/goyabu.mdx b/website/src/content/docs/docs/providers/goyabu.mdx index fd91e8a..3ea7c6a 100644 --- a/website/src/content/docs/docs/providers/goyabu.mdx +++ b/website/src/content/docs/docs/providers/goyabu.mdx @@ -1,79 +1,29 @@ --- -title: GoyabuProvider -description: Provider for goyabu.io that resolves Brazilian Portuguese anime streams via Google batchexecute. +title: goyabu +description: Anime playback source for goyabu.io — Brazilian Portuguese dub via Google batchexecute. --- -`GoyabuProvider` targets `goyabu.io`, a Brazilian Portuguese anime site. Episodes are hosted as Blogger video embeds. The provider extracts Blogger tokens from the `playersData` JS literal on each episode page and passes them to `BloggerExtractor`, which calls Google's `batchexecute` RPC to recover the actual `googlevideo.com` MP4 URLs. +`GoyabuSource` resolves Brazilian Portuguese (pt-br) dubbed anime from `goyabu.io`. Extracts Blogger video tokens from `playersData` on episode pages, then calls Google's `batchexecute` API to recover `googlevideo.com` MP4 URLs. -## Constructor +## Capabilities -```ts -import { HttpClient, GoyabuProvider } from 'anime-sdk'; - -const provider = new GoyabuProvider(new HttpClient()); - -// Override the base URL: -const provider = new GoyabuProvider(client, { - baseUrl: 'https://goyabu.io', -}); -``` - -```ts -interface GoyabuOptions { - baseUrl?: string; // defaults to 'https://goyabu.io' -} -``` +- `search`, `episodes`, `stream` ## Usage ```ts -import { HttpClient, GoyabuProvider } from 'anime-sdk'; - -const provider = new GoyabuProvider(new HttpClient()); - -// Search -const shows = await provider.search('Naruto'); -// shows[0] => { id: '/anime/naruto', title: 'Naruto', ... } +import { createSdk } from 'anime-sdk'; -// Episodes -const eps = await provider.fetchContentUnits(shows[0].id); -// eps[0] => { id: '/40742', title: 'Episódio 1', number: 1, -// availableLanguages: ['sub'] } +const sdk = createSdk({ sources: ['goyabu'] }); -// Stream -const result = await provider.resolveStream(eps[0].id); -if (result.type === 'video') { - const s = result.streams[0]; - // s.sourceUrl → 'https://...googlevideo.com/...' - // s.isHLS → false (MP4) - // s.quality → '720p' | '360p' | 'auto' -} +const results = await sdk.search('Naruto', { kind: 'anime' }); +const { items: episodes } = await sdk.episodes(results[0]); +const stream = await sdk.stream(episodes[0]); +// stream.language → 'sub' (but the audio is Brazilian Portuguese dub) ``` -## Language - -Goyabu is a Brazilian Portuguese site. The language reported per unit depends on whether the show is dubbed: - -- Shows whose media ID contains `"dublado"` report `availableLanguages: ['dub']`: the audio is PT-BR dub. -- All other shows report `availableLanguages: ['sub']`: original Japanese audio, no subtitles (the "sub" here refers to the content type, not the presence of subtitle tracks). - -The `language` parameter on `resolveStream` is ignored; stream resolution is the same regardless. - -## How it works - -1. **Search**: GET `{baseUrl}/?s={query}`. Searches for `article.boxAN` cards; extracts the `href` (must include `/anime/`) as the ID and reads the title from `.title`, `h3`, or `h2`. - -2. **Episodes**: GET the show page. Tries up to five regex patterns to extract a JS array (`allEpisodes`, `episodes`, `episodios`, etc.) from the inline `