diff --git a/.changeset/sdk-2-0.md b/.changeset/sdk-2-0.md new file mode 100644 index 0000000..9fbf989 --- /dev/null +++ b/.changeset/sdk-2-0.md @@ -0,0 +1,23 @@ +--- +'anime-sdk': major +--- + +# anime-sdk 2.0 + +A complete redesign around a single `createSdk()` factory and nine verbs. + +## Highlights + +- **`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 URL parsing on the consumer side. +- **`Score { value, scale }`**: units carried. +- **`sdk.sources(media)`**: ranked list of playable providers — safe "Watch via" dropdown. +- **`npx anime-sdk`**: zero-install server. `PORT`, `SOURCES_DISABLED`, `PROXY_*` env vars. +- **`startServer({ proxy })`**: optional `/proxy` endpoint with SSRF allowlist + HMAC signing. Every `Stream`/`Pages` URL the server emits gets rewritten to be browser-playable. +- **`downloadVideo` / `downloadMangaChapter` / `downloadMangaPage`**: built-in MP4 (via ffmpeg mux) and ZIP downloaders that work on `Stream`/`Pages` directly. +- **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/.gitignore b/.gitignore index ebc05f7..6bc9979 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ dist/ references/ .DS_Store *.log -scratch \ No newline at end of file +scratch +examples/website/tsconfig.tsbuildinfo +*.tsbuildinfo \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 722d778..eed9ade 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,69 +13,68 @@ 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, 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. -- `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. -### 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` — `source: string` identifies the originating source. `mappings: { anilist?, mal?, kitsu? }` for cross-provider IDs. +- `Episode` — `id`, `number`, `title?`, `thumbnail?`, `airDate?`, `filler?`, `recap?`, `languages?: ('sub'|'dub'|'raw')[]`. +- `Chapter` — `id`, `number`, `title?`. +- `Stream` — one playable URL per object: `url`, `source`, `server`, `quality` (`'1080p'|'720p'|'480p'|'360p'|'auto'`), `language`, `isHls`, `headers?`, `subtitles[]`. +- `Pages` — `pages: { url, width?, height? }[]`. +- `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`). 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. +**Stream sources return `Stream[]`** — one `Stream` per playable URL (one per server per language). Each `Stream` has `source` (which provider), `server` (which server within the provider), `quality`, and `language`. The SDK fans these out across all sources progressively. -All public surface is re-exported from `src/index.ts`, including the shared subtitle utilities (`normalizeSubtitleEntries`, `proxifySubtitleUrl`). +**4. Registry + SDK (`src/registry.ts`, `src/sdk.ts`, `src/progressive.ts`, `src/health.ts`)**: public API. -**4. Metadata layer (`src/meta/`)**: provider-agnostic catalogue access. +- `Registry`: holds sources, `mediaCache` (auto-populated when episodes/chapters are fetched), `mappingCache` for cross-source ID resolution. Implements `fanOutSearch`, `fanOutStream`, `streamEpisode` (auto fan-out using cached media), `streamFromSource` (single source), `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. +- `sdk.stream(episode)` → `ProgressiveResult`. With an Episode object, the SDK looks up the cached media and auto-fans-out across all stream-capable sources. With a string ID, only the source encoded in the ID is queried. +- `createSdk(opts?)`: zero-config factory that instantiates `HttpClient` + all enabled sources + `Registry`. -- `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). +**5. Server (`src/server/`)**: thin consumer of the SDK. -**5. Server (`src/server/index.ts`)**: `startServer({ providers, metaProviders?, port, proxy, cache, auth, proxyBase?, proxySignSecret?, proxyAllowedHosts? })`. +- `routes.ts`: routes that decode params → call SDK → JSON-serialize. +- `startServer({ port, sdk })`: single-call server. `sdk` defaults to `createSdk()`. +- `cli.ts`: process entry for `npx anime-sdk`. Reads `PORT`, `SOURCES_DISABLED` env vars. -Routes: +### ID space -- `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. +Every `id` field on `Media`, `Episode`, `Chapter` is a base64url-encoded JSON token: -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). +```json +{ "v": 1, "t": "media"|"episode"|"chapter", "s": "sourceId", "r": "rawId", "m": {} } +``` -`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:::`. +Consumers treat ids as opaque strings. The SDK decodes them internally to dispatch calls to the right source. -`/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. +### Extractors (`src/extractors/`) + +Stateless, take an embed URL + `HttpClient`, return `IVideoPayload[]`. Used internally by sources. `BloggerExtractor`, `Mp4UploadExtractor`, `GenericHlsExtractor`, `VidstreamingExtractor`. ## ESM import convention @@ -83,27 +82,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, `encodeId`/`decodeId`, 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 streams/pages, and (for anime) runs `captureStreamScreenshot` to screenshot a real video frame. Assertion: the PNG is >1KB. Each `Stream` is one playable URL; tests pick one to screenshot. 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 `Source` are allowed only for testing pure SDK logic** (e.g. the registry's source-ranking) where the source'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..df9e120 100644 --- a/README.md +++ b/README.md @@ -1,294 +1,187 @@ # anime-sdk -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 + +// 3. Resolve all available streams from all sources — iterate progressively +for await (const stream of sdk.stream(episodes[0])) { + console.log(stream.source); // 'allmanga' | 'gogoanime' | … + console.log(stream.server); // 'mp4upload' | 'wixmp' | … + console.log(stream.language); // 'sub' | 'dub' | 'raw' + console.log(stream.quality); // '1080p' | '720p' | 'auto' | … + console.log(stream.url); // playable HLS or MP4 URL +} -```ts -import { HttpClient, AllmangaProvider, MangadexProvider } from 'anime-sdk'; +// Or collect all at once and let the user pick: +const streams = await sdk.stream(episodes[0]); +const preferred = streams.find((s) => s.language === 'sub') ?? streams[0]; +``` -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]); +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 { startServer } from 'anime-sdk'; +startServer({ 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, userAgent: 'my-app/1.0' }, }); ``` -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). +### Proxy (browser-playable URLs) ```ts -import { HttpClient, BloggerExtractor } from 'anime-sdk'; +import { startServer } from 'anime-sdk'; -const blogger = new BloggerExtractor(new HttpClient()); -const streams = await blogger.extract('https://www.blogger.com/video.g?token=AD6v5dw…'); +startServer({ + port: 3030, + proxy: { + signSecret: process.env.PROXY_SIGN_SECRET, + allowedHosts: ['cdn.example.com'], + }, +}); ``` -### Cancellation +`stream.url`, subtitle URLs, and `pages[].url` are rewritten through `/proxy`. -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. +### Downloads ```ts -const ac = new AbortController(); -setTimeout(() => ac.abort(), 1500); -const results = await meta.search('frieren', { signal: ac.signal }); -``` +import { downloadVideo, downloadMangaChapter } from 'anime-sdk'; -## Tests - -```bash -# Everything (unit + live e2e, ~60s total) -npx vitest run +const streams = await sdk.stream(episode); +const stream = streams.find((s) => s.language === 'sub') ?? streams[0]; +await downloadVideo(stream, './episode-1.mp4', { + onProgress: ({ phase, detail }) => console.log(`[${phase}] ${detail ?? ''}`), +}); -# Just the live providers -npx vitest run tests/e2e +const pages = await sdk.pages(chapter); +await downloadMangaChapter(pages, './chapter-1.zip'); ``` -The E2E suite is intentionally not mocked. Each test: +### Error handling -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: +```ts +import { AniError, AniErrorCode } from 'anime-sdk'; + +try { + const streams = 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 + } + } +} +``` -- 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`, +### Cancellation -4. Asserts the resulting PNG is >1KB before passing. +```ts +const ac = new AbortController(); +setTimeout(() => ac.abort(), 5000); +const results = await sdk.search('frieren', { kind: 'anime', signal: ac.signal }); +``` -Screenshots land in `scratch/screenshots/screenshot_.png`. -`scratch/` is gitignored. +## 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 -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. +``` +GET /search?q=…&kind=anime → Media[] (SSE, streams as they arrive) +GET /media/:id → Media +GET /media/:id/episodes → List +GET /media/:id/chapters → List +GET /media/:id/sources → SourceInfo[] +GET /episode/:id/streams → Stream[] (SSE — all sources, all languages) +GET /episode/:id/stream?language=sub → Stream (single stream, for downloads) +GET /chapter/:id/pages → Pages +GET /browse?list=trending&kind=anime → List +GET /health → SourceHealth[] +``` -## Requirements +The `/episode/:id/streams` SSE endpoint emits one JSON object per stream as each source responds. Cross-source fan-out happens automatically when the SDK has cached media from a prior episodes call. -- Node 20+ (uses `fetch`, `globalThis.crypto.subtle`, top-level await in - tests). -- `ffmpeg` on `PATH` for the E2E suite. +## API reference -## License +Public exports: `createSdk`, `Sdk`, `startServer`, `ServerOptions`, `ProxyOptions`, `downloadVideo`, `downloadMangaChapter`, `downloadMangaPage`, `AniError`, `AniErrorCode`, `Media`, `Episode`, `Chapter`, `Stream`, `Pages`, `List`, `SourceInfo`, `SdkOptions`, `Score`. -MIT +All types are plain POJOs — `JSON.stringify` round-trips, safe for React state, Zustand, Redux. -## DMCA +## Requirements -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` for HLS video downloads (and the E2E test suite). diff --git a/examples/cli.mjs b/examples/cli.mjs index 91f6716..45c7fd6 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,57 @@ 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 ?? ['sub']).join('/')})`, + ), + 'Select episode', + ); + const episode = episodes[ei]; + process.stdout.write('...\n'); + const streams = await sdk.stream(episode); -console.log('\n─── STREAM ───'); -if (stream.type === 'video') { - for (const s of stream.streams) { + console.log(`\n─── STREAMS (${streams.length}) ───`); + for (const s of streams) { console.log( - `\n[${s.isHLS ? 'HLS' : 'MP4'}] ${s.quality}${s.language ? ' ' + s.language : ''}`, + `[${s.isHls ? 'HLS' : 'MP4'}] ${s.language} ${s.quality} server: ${s.server} source: ${s.source}`, ); - console.log(s.sourceUrl); - if (s.headers && Object.keys(s.headers).length) - console.log('headers:', JSON.stringify(s.headers)); + console.log(` ${s.url}`); } -} 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 new file mode 100644 index 0000000..9dbae90 --- /dev/null +++ b/examples/cli/index.tsx @@ -0,0 +1,477 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { render, Box, Text, useInput, useApp } from 'ink'; +import TextInput from 'ink-text-input'; +import { + createSdk, + type Media, + type Episode, + type Chapter, + type Stream, + type Pages, +} from '../../dist/index.js'; + +// ─── SDK ───────────────────────────────────────────────────────────────────── + +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: 'stream'; + episode: Episode; + streams: Stream[]; + loading: boolean; + error: string | null; + } + | { + type: 'pages'; + chapter: Chapter; + result: Pages | null; + loading: boolean; + error: string | null; + }; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +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 ─────────────────────────────────────────────────── + +function SelectList({ + items, + active, + renderItem, + maxVisible = 12, +}: { + items: T[]; + active: number; + renderItem: (item: T, isActive: boolean, index: number) => React.ReactNode; + maxVisible?: number; +}) { + 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)} + ))} + {items.length > maxVisible && ( + {` +${items.length - maxVisible} more`} + )} + + ); +} + +// ─── App ───────────────────────────────────────────────────────────────────── + +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 push = useCallback((s: Screen) => { + setScreen(s); + setActiveIdx(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]); + + // ─── 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]); + + // ─── 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]); + + // ─── Stream loader ────────────────────────────────────────────────────────── + useEffect(() => { + if (screen.type !== 'stream' || !screen.loading) return; + const ep = screen.episode; + sdk + .stream(ep) + .then((streams) => + setScreen({ type: 'stream', episode: ep, streams, loading: false, error: null }), + ) + .catch((e) => + setScreen({ + type: 'stream', + episode: ep, + streams: [], + loading: false, + error: (e as Error).message, + }), + ); + }, [screen.type === 'stream' && screen.loading]); + + // ─── Pages loader ────────────────────────────────────────────────────────── + useEffect(() => { + 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 (key.ctrl && input === 'c') exit(); + + 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'); + } + + 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' }); + } + + 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' }); + } + + 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], + streams: [], + loading: true, + error: null, + }); + } + if (key.escape) push({ type: 'media', media: screen.media }); + } + + 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 }); + } + + if (screen.type === 'stream') { + if (key.escape) + push({ + type: 'episodes', + media: { kind: 'anime' } as Media, + items: [], + loading: false, + error: null, + }); + } + + if (screen.type === 'pages') { + if (key.escape) push({ type: 'media', media: { kind: 'manga' } as Media }); + } + }); + + // ─── Render ──────────────────────────────────────────────────────────────── + return ( + + + + anime-sdk{' '} + + 2.0 + [a]nime + + [m]anga + + + {screen.type === 'home' && ( + + [s] search + [b] browse trending + ctrl+c quit + + )} + + {screen.type === 'search' && ( + + Search: + { + if (!q.trim()) return; + const results = await sdk.search(q.trim(), { kind }); + push({ type: 'results', items: results, query: q }); + setSearchInput(''); + }} + /> + + )} + + {(screen.type === 'results' || screen.type === 'browse') && ( + + + {screen.type === 'results' + ? `results for "${screen.query}" (${screen.items.length})` + : `trending ${kind}`} + + {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)}` : ''} + + + )} + /> + ↑↓ 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 + )} + {screen.media.chapterCount && ( + {screen.media.chapterCount} chapters + )} + {screen.media.description && ( + + + {truncate(stripHtml(screen.media.description), 300)} + + + )} + + [e/r] {screen.media.kind === 'manga' ? 'read chapters' : 'watch episodes'} + esc home + + + )} + + {screen.type === 'episodes' && ( + + {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('/')} + + )} + /> + ↑↓ navigate enter stream esc back + + )} + + )} + + {screen.type === 'chapters' && ( + + {screen.loading && loading chapters…} + {screen.error && {screen.error}} + {!screen.loading && !screen.error && ( + <> + + {screen.items.length} chapters + + ( + + {isActive ? '› ' : ' '} + {`Ch.${String(ch.number).padStart(3, '0')} `} + {truncate(ch.title ?? '', 40)} + + )} + /> + ↑↓ navigate enter pages esc back + + )} + + )} + + {screen.type === 'stream' && ( + + {screen.loading && resolving streams…} + {screen.error && {screen.error}} + {screen.streams.length > 0 && ( + <> + + EP.{screen.episode.number} {screen.episode.title ?? ''} + + + {screen.streams.map((s, i) => ( + + + [{s.isHls ? 'HLS' : 'MP4'}] {s.source} · {s.language} {s.quality} {s.server} + + + {s.url} + + {s.subtitles.length > 0 && ( + + subtitles: {s.subtitles.map((sub) => sub.label).join(', ')} + + )} + + ))} + + + )} + esc back + + )} + + {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 + {screen.result.pages.slice(0, 3).map((p, i) => ( + + {p.url} + + ))} + {screen.result.pages.length > 3 && ( + … +{screen.result.pages.length - 3} more + )} + + )} + esc back + + )} + + ); +} + +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/playground.mjs b/examples/playground.mjs new file mode 100644 index 0000000..cbdd57a --- /dev/null +++ b/examples/playground.mjs @@ -0,0 +1,22 @@ +import { createSdk } from '../dist/index.js'; + +const sdk = createSdk({ + http: { timeoutMs: 30000 }, +}); + +const results = await sdk.search('Berserk', { kind: 'anime' }); + +console.log({ results: results.slice(0, 3) }); +console.log(results[0]); +console.log('\n\n'); + +const { items: episodes } = await sdk.episodes(results[0]); + +console.log({ episodes: episodes.slice(0, 3) }); +console.log(episodes[0]); +console.log('\n\n'); + +const streams = await sdk.stream(episodes[0]); + +console.dir({ streams }, { depth: 100 }); +console.log('\n\n'); diff --git a/examples/server.mjs b/examples/server.mjs index cb439db..9ce599f 100644 --- a/examples/server.mjs +++ b/examples/server.mjs @@ -1,39 +1,28 @@ -import { - HttpClient, - startServer, - GogoanimeProvider, - GoyabuProvider, - AllmangaProvider, - AnimeParadiseProvider, - AnikotoProvider, - MegaPlayProvider, - MangadexProvider, - WeebcentralProvider, - MangapillProvider, -} from '../dist/index.js'; +import { startServer, createSdk } from '../dist/index.js'; -const http = new HttpClient({ timeoutMs: 30000 }); +const port = Number(process.env.PORT ?? 3030); -// simple cache -const store = new Map(); -const cache = { - get: (key) => store.get(key), - set: (key, value) => store.set(key, value), -}; +const sdk = createSdk({ + http: { timeoutMs: 30000 }, +}); + +// Proxy is enabled by default so the example frontend can play streams +// from CDNs that gate on `Referer` (megaplay, wix, googlevideo, …). +// Set PROXY_SIGN_SECRET in production; PROXY_ALLOWED_HOSTS is a +// suffix-matched SSRF allowlist (e.g. "wixstatic.com,megacdn.co"). +const allowedHosts = process.env.PROXY_ALLOWED_HOSTS + ? process.env.PROXY_ALLOWED_HOSTS.split(',') + .map((s) => s.trim()) + .filter(Boolean) + : undefined; 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), - ], - port: Number(process.env.PORT ?? 3030), - proxy: true, - cache, + port, + sdk, + proxy: { + signSecret: process.env.PROXY_SIGN_SECRET, + allowedHosts, + }, }); + +console.log(`anime-sdk example server listening on http://localhost:${port}`); diff --git a/examples/website/.env.example b/examples/website/.env.example index 2e3a845..761fbb2 100644 --- a/examples/website/.env.example +++ b/examples/website/.env.example @@ -4,3 +4,12 @@ # SDK server port (used by vite.config.ts proxy target) # API_PORT=3030 + +# Stream proxy is on by default in examples/server.mjs. For production, +# set a signing secret so the /proxy endpoint can't be turned into an +# open HTTP relay. Generate one with: openssl rand -hex 32 +# PROXY_SIGN_SECRET= + +# Optional SSRF allowlist — suffix-matched hostnames the proxy is allowed +# to fetch. Leave unset in dev (proxy will fetch anything). +# PROXY_ALLOWED_HOSTS=wixstatic.com,megacdn.co,googlevideo.com 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..29b6c39 100644 --- a/examples/website/src/api.ts +++ b/examples/website/src/api.ts @@ -1,60 +1,292 @@ /// 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(); }); -export const search = (provider: string, q: string) => get('/search', { provider, q }); +// ─── SDK types (mirrors src/types.ts) ──────────────────────────────────────── -export const content = (provider: string, mediaId: string) => - get('/content', { provider, mediaId }); +export type Quality = '1080p' | '720p' | '480p' | '360p' | 'auto'; +export type Language = 'sub' | 'dub' | 'raw'; -export const stream = (provider: string, unitId: string, language: string) => - get('/stream', { provider, unitId, language }); +export interface MediaTitle { + preferred: string; + english?: string; + romaji?: string; + native?: string; +} + +export interface MediaCover { + url: string; + color?: string; +} -// Types matching IVideoPayload / ResolvedMediaStream from the SDK -export type Lang = 'sub' | 'dub' | 'raw'; +export interface Score { + value: number; + scale: number; +} -export interface SearchResult { +export interface Media { id: string; - title: string; - catalogType: 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; + source: string; + mappings: { anilist?: number; mal?: number; kitsu?: number }; } export interface Episode { id: string; - title: string; number: number; - availableLanguages: Lang[]; + title?: string; + thumbnail?: string; + airDate?: string; + filler?: boolean; + recap?: boolean; + languages?: Language[]; +} + +export interface Chapter { + id: string; + number: number; + title?: string; } -export interface SubtitleTrack { +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; + source: string; + server: string; + quality: Quality; + language: Language; + isHls: boolean; headers?: Record; - subtitles?: SubtitleTrack[]; + subtitles: Subtitle[]; } -export interface MangaStream { - imageUrls: string[]; - headers?: Record; +export interface Pages { + pages: { url: string; width?: number; height?: number }[]; +} + +export interface List { + items: T[]; + nextCursor?: string; + total?: number; +} + +export interface SourceInfo { + id: string; + status: 'available' | 'incompatible' | 'error'; + episodeCount?: number; + successRate?: number; +} + +export interface SourceHealth { + id: string; + successRate: number; + avgLatencyMs: number; + calls: number; +} + +// ─── API calls ─────────────────────────────────────────────────────────────── + +export const search = (q: string, kind: 'anime' | 'manga' = 'anime'): Promise => + get('/search', { q, kind }); + +export const mediaInfo = (id: string): Promise => get(`/media/${encodeURIComponent(id)}`); + +export const mediaEpisodes = (id: string, cursor?: string): Promise> => + get(`/media/${encodeURIComponent(id)}/episodes`, cursor ? { cursor } : {}); + +export const mediaChapters = (id: string, cursor?: string): Promise> => + get(`/media/${encodeURIComponent(id)}/chapters`, cursor ? { cursor } : {}); + +export const mediaSources = (id: string): Promise => + get(`/media/${encodeURIComponent(id)}/sources`); + +export function episodeStreams( + id: string, + _mediaId?: string, + onStream?: (s: Stream) => void, + onDone?: () => void, + onError?: (e: string) => void, +): { close: () => void } { + const url = `${API}/episode/${encodeURIComponent(id)}/streams`; + const es = new EventSource(url); + es.onmessage = (ev) => { + try { + const data = JSON.parse(ev.data); + if (data.error) { + onError?.(data.error); + } else { + onStream?.(data as Stream); + } + } catch {} + }; + es.onerror = () => { + onDone?.(); + es.close(); + }; + return { close: () => es.close() }; +} + +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) } : {}), + }); + +export const health = (): Promise => get('/health'); + +// ─── Downloads ─────────────────────────────────────────────────────────────── +// +// The server runs the download in the background and streams progress over +// SSE. When complete it returns a single-use `token` that names a temp file +// on disk; the client GETs /download/.../file?token=… to pull the bytes. + +export interface VideoDownloadCallbacks { + onProgress: (info: { phase: string; detail?: string }) => void; + onComplete: (token: string) => void; + onError: (msg: string) => void; +} + +export interface ChapterDownloadCallbacks { + onProgress: (info: { downloaded: number; total: number }) => void; + onComplete: (token: string) => void; + onError: (msg: string) => void; +} + +export interface DownloadHandle { + close: () => void; +} + +export function watchVideoDownload( + episodeId: string, + language: Language, + cb: VideoDownloadCallbacks, +): DownloadHandle { + const url = `${API}/download/video/progress?episodeId=${encodeURIComponent(episodeId)}&language=${language}`; + const es = new EventSource(url); + let done = false; + const close = () => { + if (!done) { + done = true; + es.close(); + } + }; + es.onmessage = (e) => { + try { + const data = JSON.parse(e.data); + if (data.type === 'progress') cb.onProgress({ phase: data.phase, detail: data.detail }); + else if (data.type === 'complete') { + cb.onComplete(data.token); + close(); + } else if (data.type === 'error') { + cb.onError(data.message); + close(); + } + } catch (err) { + cb.onError(err instanceof Error ? err.message : String(err)); + close(); + } + }; + es.onerror = () => { + if (!done) { + cb.onError('SSE connection error'); + close(); + } + }; + return { close }; +} + +export function watchChapterDownload( + chapterId: string, + cb: ChapterDownloadCallbacks, +): DownloadHandle { + const url = `${API}/download/manga/chapter/progress?chapterId=${encodeURIComponent(chapterId)}`; + const es = new EventSource(url); + let done = false; + const close = () => { + if (!done) { + done = true; + es.close(); + } + }; + es.onmessage = (e) => { + try { + const data = JSON.parse(e.data); + if (data.type === 'progress') + cb.onProgress({ downloaded: data.downloaded, total: data.total }); + else if (data.type === 'complete') { + cb.onComplete(data.token); + close(); + } else if (data.type === 'error') { + cb.onError(data.message); + close(); + } + } catch (err) { + cb.onError(err instanceof Error ? err.message : String(err)); + close(); + } + }; + es.onerror = () => { + if (!done) { + cb.onError('SSE connection error'); + close(); + } + }; + return { close }; +} + +export function downloadFileUrl(kind: 'video' | 'manga-chapter', token: string): string { + const path = kind === 'video' ? '/download/video/file' : '/download/manga/chapter/file'; + return `${API}${path}?token=${encodeURIComponent(token)}`; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +export function formatScore(s?: Score): string { + if (!s) return 'N/A'; + return ((s.value / s.scale) * 10).toFixed(1); } -export interface ResolvedStream { - type: 'video' | 'manga' | 'live'; - streams?: VideoStream[]; - pages?: MangaStream; +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..8399d5c 100644 --- a/examples/website/src/components/Layout.tsx +++ b/examples/website/src/components/Layout.tsx @@ -12,22 +12,35 @@ function buildCrumbs(pathname: string, sp: URLSearchParams): Crumb[] { 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 (title) crumbs.push({ label: title }); + return crumbs; } - if (ep) crumbs.push({ label: ep }); + if (pathname === '/episodes') { + if (provider) crumbs.push({ label: provider, href: `/?provider=${provider}` }); + if (title) crumbs.push({ label: title }); + return crumbs; + } + + if (pathname === '/stream') { + 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 (provider) crumbs.push({ label: provider }); return crumbs; } @@ -38,27 +51,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..189c50f 100644 --- a/examples/website/src/index.css +++ b/examples/website/src/index.css @@ -1,8 +1,36 @@ @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; + border-color: var(--color-base-200); } html, @@ -10,7 +38,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 +47,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..77703f5 --- /dev/null +++ b/examples/website/src/pages/Browse.tsx @@ -0,0 +1,188 @@ +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 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.Media; onClick: () => void }) { + const accent = result.cover?.color; + return ( + + ); +} + +export default function Browse() { + const navigate = useNavigate(); + const [sp, setSp] = useSearchParams(); + + 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', list, kind, season, year], + queryFn: () => + api.browse(list, kind, { + 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?q=${encodeURIComponent(searchInput.trim())}&kind=${kind}`); + }; + + const goMedia = (r: api.Media) => navigate(`/media?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?q=${encodeURIComponent(searchInput.trim())}&kind=${kind}`); + }} + placeholder="search anime, manga..." + /> + +
+ +
+
+ {(['anime', 'manga'] as const).map((k) => ( + + ))} +
+
+ +
+ {LISTS.map((l) => ( + + ))} + + {list === '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.items.map((r) => ( + goMedia(r)} /> + ))} +
+ )} + + {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 9ac2631..a2cf025 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,146 @@ export default function Episodes() { const navigate = useNavigate(); const [sp] = useSearchParams(); - const provider = sp.get('provider') || ''; - const mediaId = sp.get('mid') || ''; + const mediaId = sp.get('id') || ''; const title = sp.get('title') || mediaId; - const type = sp.get('type') || 'ANIME'; + const kind = (sp.get('kind') ?? 'anime') as 'anime' | 'manga'; - const isManga = type === 'MANGA'; - const unitLabel = isManga ? 'Chapter' : 'EP'; + const isManga = kind === 'manga'; + const unitLabel = isManga ? 'Ch' : 'EP'; - const { data, isFetching, isError, error } = useQuery({ - queryKey: ['content', provider, mediaId], - queryFn: () => api.content(provider, mediaId), - enabled: !!(provider && mediaId), + const { + data: episodeList, + isFetching: epFetching, + isError: epError, + error: epErr, + } = useQuery>({ + queryKey: ['episodes', mediaId], + queryFn: () => api.mediaEpisodes(mediaId), + enabled: !isManga && !!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 { + data: chapterList, + isFetching: chFetching, + isError: chError, + error: chErr, + } = useQuery>({ + queryKey: ['chapters', mediaId], + queryFn: () => api.mediaChapters(mediaId), + enabled: isManga && !!mediaId, + }); + + 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?chid=${encodeURIComponent(ch.id)}&title=${encodeURIComponent(title)}&mid=${encodeURIComponent(mediaId)}`, + ); + } else { + const ep = item as api.Episode; + const params = new URLSearchParams({ + epid: ep.id, + title, + mid: mediaId, + }); + // Thread the episode's available languages through to the stream page + // so its language switcher reflects what's actually playable. + if (ep.languages && ep.languages.length > 0) { + params.set('langs', ep.languages.join(',')); + } + navigate(`/stream?${params.toString()}`); + } + }; return (
-

{title}

- {data && ( -

- {data.length} {isManga ? 'chapters' : 'episodes'} +

+ + ← info + +

{title}

+
+ {items && ( +

+ {items.length} {isManga ? 'chapters' : 'episodes'}

)}
- {isFetching &&

fetching...

} + {isFetching &&

fetching...

} {isError &&

{String(error)}

} - {data && ( -
- {data.map((ep) => ( - - ))} + {items && items.length === 0 && !isFetching && ( +

+ no {isManga ? 'chapters' : 'episodes'} returned from any source +

+ )} + + {items && items.length > 0 && ( +
+ {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 new file mode 100644 index 0000000..9021b2d --- /dev/null +++ b/examples/website/src/pages/Media.tsx @@ -0,0 +1,150 @@ +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import * as api from '../api'; +import { Button } from '../components/ui/Button'; + +export default function Media() { + const navigate = useNavigate(); + const [sp] = useSearchParams(); + + const id = sp.get('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 ( +
+

loading...

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

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

+
+ ); + } + + const isManga = data.kind === 'manga'; + const unitLabel = isManga ? 'Read' : 'Watch'; + + const availableSources = sources?.filter((s) => s.status === 'available') ?? []; + + const watch = () => + navigate( + `/episodes?id=${encodeURIComponent(id)}&title=${encodeURIComponent(data.title.preferred)}&kind=${data.kind}`, + ); + + return ( +
+ {data.banner && ( +
+ +
+
+ )} + +
+
+
+ {data.cover?.url ? ( + {data.title.preferred} + ) : ( +
+ )} +
+ +
+

+ {data.title.preferred} +

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

{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 && ( + ★ {api.formatScore(data.score)} + )} +
+ +
+ {data.episodeCount != null && {data.episodeCount} eps} + {data.chapterCount != null && {data.chapterCount} chapters} +
+
+
+ + {data.description && ( +

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

+ )} + +
+ +
+ + {availableSources.length > 0 && ( +
+

AVAILABLE ON

+
+ {availableSources.map((s) => ( + + {s.id} + {s.successRate != null && ( + {(s.successRate * 100).toFixed(0)}% + )} + + ))} +
+
+ )} + +
+

SOURCE

+ {data.source} +
+ + {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 f2d4895..bba7b98 100644 --- a/examples/website/src/pages/Search.tsx +++ b/examples/website/src/pages/Search.tsx @@ -2,117 +2,118 @@ import { useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import * as api from '../api'; - -const PROVIDERS = [ - 'megaplay', - 'allmanga', - 'animeparadise', - 'anikoto', - 'gogoanime', - 'goyabu', - 'mangadex', - 'weebcentral', - 'mangapill', -]; +import { Button } from '../components/ui/Button'; +import { Input } from '../components/ui/Input'; export default function Search() { const navigate = useNavigate(); const [sp, setSp] = useSearchParams(); - const provider = sp.get('provider') || PROVIDERS[0]; + const kind = (sp.get('kind') ?? 'anime') as 'anime' | 'manga'; 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 submit = (e: React.FormEvent) => { e.preventDefault(); setSp((prev) => { const next = new URLSearchParams(prev); - next.set('provider', provider); next.set('q', input); return next; }); }; - const setProvider = (p: string) => - setSp((prev) => { - const next = new URLSearchParams(prev); - next.set('provider', p); - return next; - }); + const { data, isFetching, isError, error } = useQuery({ + queryKey: ['search', kind, initialQ], + queryFn: () => api.search(initialQ, kind), + enabled: !!initialQ, + }); - const goEpisodes = (result: api.SearchResult) => - navigate( - `/episodes?provider=${provider}&mid=${encodeURIComponent(result.id)}&title=${encodeURIComponent(result.title)}&type=${result.catalogType}`, - ); + const goMedia = (m: api.Media) => navigate(`/media?id=${encodeURIComponent(m.id)}`); return (
-
- {PROVIDERS.map((p) => ( - + {k} + ))}
-
- + setInput(e.target.value)} + onChange={setInput} + onSubmit={() => { + setSp((prev) => { + const next = new URLSearchParams(prev); + next.set('q', input); + 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}) + {data && data.length === 0 && !isFetching && ( +

+ no {kind} matched "{initialQ}" +

+ )} + + {data && data.length > 0 && ( +
+
+ RESULTS ({data.length})
{data.map((r) => ( ))}
diff --git a/examples/website/src/pages/Stream.tsx b/examples/website/src/pages/Stream.tsx index 0b87507..c828301 100644 --- a/examples/website/src/pages/Stream.tsx +++ b/examples/website/src/pages/Stream.tsx @@ -1,193 +1,48 @@ -import { useEffect, useRef, useState } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; +import { useEffect, useRef, useState, useCallback } from 'react'; +import { useNavigate, useSearchParams, Link } from 'react-router-dom'; import Hls from 'hls.js'; import * as api from '../api'; - -// ─── Download button with SSE progress ──────────────────────────────────────── - -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 ( - - ); - } - - // active - return ( -
- {label} - -
- ); -} +import { Combobox } from '../components/ui/Select'; +import { Button } from '../components/ui/Button'; function Player({ stream, - subtitles, - langUI, + activeUrl, + isHls, }: { - 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; + stream: api.Stream; + activeUrl: string; + isHls: boolean; }) { const ref = useRef(null); const [playerError, setPlayerError] = useState(null); - 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 [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 (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); - // 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); - } else { - hls!.subtitleTrack = -1; - } - }); - hls.loadSource(stream.sourceUrl); + hls.loadSource(activeUrl); hls.attachMedia(v); hlsRef.current = hls; v.play().catch(() => {}); } else if (v.canPlayType('application/vnd.apple.mpegurl')) { - v.src = stream.sourceUrl; + v.src = activeUrl; v.play().catch(() => {}); } else { setPlayerError('HLS not supported in this browser'); } } else { - v.src = stream.sourceUrl; + v.src = activeUrl; v.play().catch(() => {}); } @@ -196,10 +51,8 @@ function Player({ hlsRef.current = undefined; v.src = ''; }; - }, [stream.sourceUrl, stream.isHLS, externalSubs.length]); + }, [activeUrl, isHls]); - // 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; @@ -207,21 +60,12 @@ 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 ( -
-

{playerError}

+
+

{playerError}

); } @@ -232,54 +76,165 @@ 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) => ( - + {stream.subtitles.map((s, i) => ( + ))} - {langUI} - {hasSubtitleUI && ( -
- SUB - + {stream.subtitles.length > 0 && ( +
+ SUB + setActiveSub(Number(v))} + options={[ + { value: '-1', label: 'off' }, + ...stream.subtitles.map((s, i) => ({ value: String(i), label: s.label })), + ]} + />
)}
); } -function MangaReader({ pages }: { pages: api.MangaStream }) { +interface DownloadProgress { + active: boolean; + phase?: string; + detail?: string; + downloaded?: number; + total?: number; + error?: string; +} + +function VideoDownloadButton({ + episodeId, + language, + filename, +}: { + episodeId: string; + language: api.Language; + filename: string; +}) { + const [progress, setProgress] = useState({ active: false }); + const handleRef = useRef(null); + + useEffect(() => () => handleRef.current?.close(), []); + + const start = () => { + setProgress({ active: true, phase: 'starting' }); + handleRef.current = api.watchVideoDownload(episodeId, language, { + onProgress: ({ phase, detail }) => setProgress({ active: true, phase, detail }), + onComplete: (token) => { + setProgress({ active: false }); + triggerDownload(api.downloadFileUrl('video', token), `${filename}.mp4`); + }, + onError: (msg) => setProgress({ active: false, error: msg }), + }); + }; + + if (progress.active) { + return ( +
+ DOWNLOADING + + {progress.phase} + {progress.detail ? ` · ${progress.detail.slice(0, 60)}` : ''} + + +
+ ); + } + + return ( +
+ + {progress.error && ( + {progress.error.slice(0, 80)} + )} +
+ ); +} + +function ChapterDownloadButton({ chapterId, filename }: { chapterId: string; filename: string }) { + const [progress, setProgress] = useState({ active: false }); + const handleRef = useRef(null); + + useEffect(() => () => handleRef.current?.close(), []); + + const start = () => { + setProgress({ active: true, downloaded: 0, total: 0 }); + handleRef.current = api.watchChapterDownload(chapterId, { + onProgress: ({ downloaded, total }) => setProgress({ active: true, downloaded, total }), + onComplete: (token) => { + setProgress({ active: false }); + triggerDownload(api.downloadFileUrl('manga-chapter', token), `${filename}.zip`); + }, + onError: (msg) => setProgress({ active: false, error: msg }), + }); + }; + + if (progress.active) { + const total = progress.total ?? 0; + const done = progress.downloaded ?? 0; + return ( +
+ DOWNLOADING + + {done}/{total > 0 ? total : '?'} pages + + +
+ ); + } + + return ( +
+ + {progress.error && ( + {progress.error.slice(0, 80)} + )} +
+ ); +} + +function triggerDownload(url: string, filename: string): void { + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); +} + +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); - - // 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), - enabled: !!(provider && mediaId), - staleTime: 5 * 60 * 1000, - }); - - 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']; - - // If our current `lang` isn't one this episode supports, drop to the first - // language that *is* supported. + + const isManga = !!chapterId; + + // Progressive streams — arrive from all sources as SSE events + const [streams, setStreams] = useState([]); + const [streamsLoading, setStreamsLoading] = useState(false); + const [streamsError, setStreamsError] = useState(null); + const [activeIdx, setActiveIdx] = useState(null); + + const subStreams = streams.filter((s) => s.language === 'sub'); + const dubStreams = streams.filter((s) => s.language === 'dub'); + const rawStreams = streams.filter((s) => s.language === 'raw'); + const activeStream = activeIdx != null ? streams[activeIdx] : (subStreams[0] ?? streams[0]); + + // Reset when episode changes 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 streams = data?.type === 'video' ? (data.streams ?? []) : []; - const active = streams[activeIdx] ?? null; - const subtitles = active?.subtitles ?? []; - - const prevEp = currentIdx > 0 ? episodes![currentIdx - 1] : null; - const nextEp = - currentIdx >= 0 && currentIdx < (episodes?.length ?? 0) - 1 ? episodes![currentIdx + 1] : null; - - const goEpisode = (ep: api.Episode) => - navigate( - `/stream?provider=${provider}&uid=${encodeURIComponent(ep.id)}` + - `&title=${encodeURIComponent(title)}&ep=${encodeURIComponent(`${unitPrefix}.${String(ep.number).padStart(3, '0')}`)}&mid=${encodeURIComponent(mediaId)}&type=${type}`, + if (!episodeId) return; + setStreams([]); + setStreamsLoading(true); + setStreamsError(null); + setActiveIdx(null); + + const handle = api.episodeStreams( + episodeId, + mediaId || undefined, + (s) => { + setStreams((prev) => [...prev, s]); + }, + () => setStreamsLoading(false), + (e) => { + setStreamsError(e); + setStreamsLoading(false); + }, ); + return () => handle.close(); + }, [episodeId, mediaId]); + + const [pagesData, setPagesData] = useState(null); + const [pagesFetching, setPagesFetching] = useState(false); + const [pagesError, setPagesError] = useState(null); - // Reset active source when stream changes useEffect(() => { - setActiveIdx(0); - }, [unitId, lang]); + if (!chapterId) return; + setPagesFetching(true); + api + .chapterPages(chapterId) + .then((p) => { + setPagesData(p); + setPagesFetching(false); + }) + .catch((e) => { + setPagesError(String(e)); + setPagesFetching(false); + }); + }, [chapterId]); + + const activeUrl = activeStream?.url ?? ''; + const activeIsHls = activeStream?.isHls ?? false; + + const isFetching = streamsLoading || pagesFetching; + const isError = !!streamsError || !!pagesError; + const error = streamsError || pagesError; + + const filename = `${(title || 'episode').replace(/[^a-z0-9_-]+/gi, '_').slice(0, 40)}_${ + isManga ? 'chapter' : 'episode' + }`; return (
+ {mediaId && ( +
+ + ← {title || 'back'} + +
+ )} +
- {isFetching && ( -
-

- resolving {isManga ? 'pages' : 'stream'}... + {isFetching && !activeStream && ( +

+

+ resolving {isManga ? 'pages' : 'streams'}...

)} - {isError && ( -
+ {isError && !activeStream && ( +

{String(error)}

)} - {data?.type === 'video' && active && ( - 1 && ( -
- LANG - {availableLangs.map((l) => ( - - ))} -
- ) - } - /> + + {activeStream && ( + )} - {data?.type === 'manga' && data.pages && ( - <> - -
- + + {/* Manga chapter controls live above the reader so they aren't + buried under hundreds of scrolling pages. */} + {pagesData && ( +
+ + {pagesData.pages.length} pages + +
+
- {availableLangs.length > 1 && ( -
- LANG - {availableLangs.map((l) => ( - - ))} -
- )} - +
)} + + {pagesData && }
- {/* Episode navigation */} - {episodes && ( -
-
- -
- - -
-
+ {/* Stream picker: one row per language */} + {streams.length > 0 && ( +
+ {( + [ + { label: 'SUB', rows: subStreams }, + { label: 'DUB', rows: dubStreams }, + { label: 'RAW', rows: rawStreams }, + ] as const + ).map(({ label, rows }) => + rows.length === 0 ? null : ( +
+ + {label} + +
+ {rows.map((s, i) => { + const idx = streams.indexOf(s); + const active = s === activeStream; + return ( + + ); + })} +
+
+ ), + )} - {showEpisodes && ( -
- {episodes.map((ep) => { - const isCurrent = ep.number === currentEpNum; - return ( - - ); - })} + {activeStream && ( +
+
+ {activeStream.source} · {activeStream.server} · {activeStream.quality} +
+
+ +
)} -
- )} - {/* 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]" - > - {s.isHLS ? '↗' : '↓'} - -
- ); - })} + {streamsLoading && ( +
+ loading… +
+ )}
)}
diff --git a/examples/website/tsconfig.tsbuildinfo b/examples/website/tsconfig.tsbuildinfo deleted file mode 100644 index ad87b91..0000000 --- a/examples/website/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"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 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/config.ts b/src/config.ts new file mode 100644 index 0000000..4bd8f67 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,25 @@ +export interface SdkOptions { + sources?: string[]; + disabled?: string[]; + http?: { + timeoutMs?: number; + retries?: number; + userAgent?: string; + }; +} + +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/download/download.ts b/src/download/download.ts index 7e280a7..fb0cd94 100644 --- a/src/download/download.ts +++ b/src/download/download.ts @@ -1,27 +1,25 @@ -import { execSync } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; -import { IVideoPayload, IMangaPayload } from '../types/index.js'; - -// ─── Types ─────────────────────────────────────────────────────────────────── +import { execSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { Stream, Pages } from '../types.js'; export interface DownloadVideoOptions { - /** Called periodically with progress info. */ + /** Called with phase/detail strings as the download progresses. */ onProgress?: (info: { phase: string; detail?: string }) => void; - /** Timeout in ms for the overall ffmpeg process. Default 300000 (5 min). */ + /** Overall ffmpeg timeout in ms (default 300000). */ timeoutMs?: number; + /** Override stream headers (defaults to `stream.headers`). */ + headers?: Record; } export interface DownloadVideoResult { outputPath: string; - stream: IVideoPayload; fileSize: number; } export interface DownloadMangaPageOptions { - /** Custom headers to use instead of the ones on IMangaPayload. */ headers?: Record; - /** Timeout in ms for the fetch. Default 30000. */ + /** Single-page fetch timeout in ms (default 30000). */ timeoutMs?: number; } @@ -33,10 +31,10 @@ export interface DownloadMangaPageResult { } export interface DownloadMangaChapterOptions { - /** Called periodically with progress info. */ onProgress?: (info: { downloaded: number; total: number }) => void; - /** Timeout in ms per page fetch. Default 30000. */ + /** Per-page fetch timeout in ms (default 30000). */ timeoutMs?: number; + headers?: Record; } export interface DownloadMangaChapterResult { @@ -52,10 +50,6 @@ interface HlsSegment { duration: number; } -/** - * Parse an M3U8 master playlist and return variant playlist URLs, - * ordered as they appear (typically lowest → highest quality). - */ export function parseHlsMaster(content: string, baseUrl: string): string[] { const variants: string[] = []; for (const line of content.split('\n').map((l) => l.trim())) { @@ -69,9 +63,6 @@ export function parseHlsMaster(content: string, baseUrl: string): string[] { return variants; } -/** - * Parse an M3U8 media playlist and return segment URLs with durations. - */ export function parseHlsSegments(content: string, baseUrl: string): HlsSegment[] { const segments: HlsSegment[] = []; let dur = 0; @@ -90,9 +81,6 @@ export function parseHlsSegments(content: string, baseUrl: string): HlsSegment[] return segments; } -/** - * Detect image file extension from Content-Type header. - */ export function detectImageExtension(contentType: string): string { const ct = contentType.toLowerCase(); if (ct.includes('png')) return '.png'; @@ -100,12 +88,9 @@ export function detectImageExtension(contentType: string): string { if (ct.includes('gif')) return '.gif'; if (ct.includes('bmp')) return '.bmp'; if (ct.includes('avif')) return '.avif'; - // Default to jpg for jpeg, octet-stream, or unknown return '.jpg'; } -// ─── Default fetch headers ────────────────────────────────────────────────── - const DEFAULT_UA = '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'; @@ -115,85 +100,57 @@ function mergeHeaders(extra?: Record): Record { // ─── Video Download ───────────────────────────────────────────────────────── -/** - * Download a video stream to a `.mp4` file. Tries each stream candidate in - * order until one succeeds. HLS streams are muxed via `ffmpeg -i -c copy`. - * Direct MP4 streams are downloaded via fetch. - * - * @param streams - One or more `IVideoPayload` candidates (from `resolveStream`) - * @param outputPath - Destination file path (must end in `.mp4`) - * @param options - Optional progress/timeout configuration - * @returns Info about the successful download - */ export async function downloadVideo( - streams: IVideoPayload | IVideoPayload[], + stream: Stream, outputPath: string, options?: DownloadVideoOptions, ): Promise { - const list = Array.isArray(streams) ? streams : [streams]; - if (list.length === 0) throw new Error('downloadVideo: streams array is empty'); + const headers = options?.headers ?? stream.headers ?? {}; const dir = path.dirname(outputPath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); const timeout = options?.timeoutMs ?? 300_000; - const errors: string[] = []; - for (let i = 0; i < list.length; i++) { - const candidate = list[i]; - options?.onProgress?.({ - phase: 'resolving', - detail: `Trying candidate ${i + 1}/${list.length}: ${candidate.sourceUrl.slice(0, 120)}`, - }); + let target = stream.url; + let hls = stream.isHls; - try { - let target = candidate.sourceUrl; - let isHls = candidate.isHLS; - const headers = candidate.headers ?? {}; - - // Probe if we're not sure - if (!isHls && !target.includes('.m3u8') && !target.includes('.mp4')) { - const probed = await probeIsVideo(target, headers); - if (!probed.isVideo) { - const scraped = await scrapeForStreamUrl(target, headers); - if (scraped) { - target = scraped.url; - isHls = scraped.isHls; - } - } + if (!hls && !target.includes('.m3u8') && !target.includes('.mp4')) { + const probed = await probeIsVideo(target, headers); + if (!probed.isVideo) { + const scraped = await scrapeForStreamUrl(target, headers); + if (scraped) { + target = scraped.url; + hls = scraped.isHls; } + } + } - if (isHls || target.includes('.m3u8')) { - options?.onProgress?.({ - phase: 'downloading', - detail: 'Downloading HLS segments manually', - }); - await downloadHlsSegments(target, outputPath, headers, timeout, options?.onProgress); - } else { - options?.onProgress?.({ phase: 'downloading', detail: 'Downloading MP4 directly' }); - await downloadMp4Direct(target, outputPath, headers, timeout); - } + options?.onProgress?.({ + phase: 'resolving', + detail: `Downloading: ${target.slice(0, 120)}`, + }); - const stat = fs.statSync(outputPath); - if (stat.size < 1024) { - throw new Error(`Downloaded file is too small (${stat.size} bytes)`); - } + if (hls || target.includes('.m3u8')) { + options?.onProgress?.({ + phase: 'downloading', + detail: 'Downloading HLS segments', + }); + await downloadHlsSegments(target, outputPath, headers, timeout, options?.onProgress); + } else { + options?.onProgress?.({ phase: 'downloading', detail: 'Downloading MP4 directly' }); + await downloadMp4Direct(target, outputPath, headers, timeout); + } - options?.onProgress?.({ phase: 'complete', detail: outputPath }); - return { outputPath, stream: candidate, fileSize: stat.size }; - } catch (e) { - const msg = (e as Error).message; - errors.push(`#${i + 1} (${candidate.sourceUrl.slice(0, 60)}…): ${msg}`); - } + const stat = fs.statSync(outputPath); + if (stat.size < 1024) { + throw new Error(`Downloaded file is too small (${stat.size} bytes)`); } - throw new Error( - `downloadVideo exhausted all ${list.length} candidate(s).\n` + - errors.map((e) => ` - ${e}`).join('\n'), - ); + options?.onProgress?.({ phase: 'complete', detail: outputPath }); + return { outputPath, fileSize: stat.size }; } -/** Probe a URL to check if it serves video bytes. */ async function probeIsVideo( url: string, headers: Record, @@ -224,7 +181,6 @@ async function probeIsVideo( } } -/** Scrape an HTML embed page for a stream URL. */ async function scrapeForStreamUrl( pageUrl: string, headers: Record, @@ -271,6 +227,8 @@ async function scrapeForStreamUrl( const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const IEND_MAGIC = Buffer.from([0x49, 0x45, 0x4e, 0x44]); +// Some CDNs disguise .ts segments as PNGs to evade adblockers. Strip the +// header so ffmpeg sees a clean transport stream. function stripPngHeader(buffer: Buffer): Buffer { if (buffer.length > 8 && buffer.subarray(0, 8).equals(PNG_MAGIC)) { const idx = buffer.indexOf(IEND_MAGIC); @@ -282,10 +240,6 @@ function stripPngHeader(buffer: Buffer): Buffer { return buffer; } -/** - * Download an HLS stream by manually downloading segments, stripping PNG headers, - * and concatenating them into a temp file, then muxing to MP4 using ffmpeg. - */ async function downloadHlsSegments( playlistUrl: string, outputPath: string, @@ -299,11 +253,10 @@ async function downloadHlsSegments( throw new Error(`Playlist ${res.status} ${res.statusText} (${currentUrl.slice(0, 120)})`); let playlist = await res.text(); - // Walk down master → variant playlists (max 2 hops) for (let hops = 0; hops < 2 && playlist.includes('#EXT-X-STREAM-INF'); hops++) { const variants = parseHlsMaster(playlist, currentUrl); if (variants.length === 0) throw new Error('Master playlist has no variants'); - currentUrl = variants[variants.length - 1]; // pick highest quality (last) + currentUrl = variants[variants.length - 1]; res = await fetch(currentUrl, { headers: mergeHeaders(headers) }); if (!res.ok) throw new Error(`Variant ${res.status} (${currentUrl.slice(0, 120)})`); playlist = await res.text(); @@ -320,10 +273,7 @@ async function downloadHlsSegments( const fd = fs.openSync(tmpTs, 'a'); try { for (let i = 0; i < segments.length; i++) { - onProgress?.({ - phase: 'downloading', - detail: `Segment ${i + 1}/${segments.length}`, - }); + onProgress?.({ phase: 'downloading', detail: `Segment ${i + 1}/${segments.length}` }); const seg = segments[i]; const segRes = await fetch(seg.url, { headers: mergeHeaders(headers) }); @@ -346,23 +296,19 @@ async function downloadHlsSegments( '-c copy', '-movflags +faststart', JSON.stringify(outputPath), - ] - .filter(Boolean) - .join(' '); + ].join(' '); try { execSync(cmd, { stdio: 'pipe', timeout: timeoutMs, maxBuffer: 50 * 1024 * 1024 }); - } catch (e: any) { - const stderr = e.stderr ? e.stderr.toString() : ''; - throw new Error(`ffmpeg failed: ${e.message}\n${stderr}`); + } catch (e) { + const err = e as { stderr?: Buffer; message: string }; + const stderr = err.stderr ? err.stderr.toString() : ''; + throw new Error(`ffmpeg failed: ${err.message}\n${stderr}`); } if (fs.existsSync(tmpTs)) fs.unlinkSync(tmpTs); } -/** - * Download a direct MP4 URL to disk using fetch streaming. - */ async function downloadMp4Direct( mp4Url: string, outputPath: string, @@ -373,10 +319,7 @@ async function downloadMp4Direct( const timer = setTimeout(() => ctrl.abort(), timeoutMs); try { - const res = await fetch(mp4Url, { - headers: mergeHeaders(headers), - signal: ctrl.signal, - }); + const res = await fetch(mp4Url, { headers: mergeHeaders(headers), signal: ctrl.signal }); if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`); if (!res.body) throw new Error('Response body is null'); @@ -399,38 +342,27 @@ async function downloadMp4Direct( // ─── Manga Download ───────────────────────────────────────────────────────── -/** - * Download a single manga page image to disk. - * - * @param pages - The `IMangaPayload` from `resolveStream` - * @param pageIndex - 0-based page index - * @param outputDir - Directory to save the image (filename is auto-generated) - * @param options - Optional configuration - */ export async function downloadMangaPage( - pages: IMangaPayload, + pages: Pages, pageIndex: number, outputDir: string, options?: DownloadMangaPageOptions, ): Promise { - if (pageIndex < 0 || pageIndex >= pages.imageUrls.length) { - throw new Error(`Page index ${pageIndex} out of range (0-${pages.imageUrls.length - 1})`); + if (pageIndex < 0 || pageIndex >= pages.pages.length) { + throw new Error(`Page index ${pageIndex} out of range (0-${pages.pages.length - 1})`); } if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true }); - const url = pages.imageUrls[pageIndex]; - const headers = options?.headers ?? pages.headers ?? {}; + const url = pages.pages[pageIndex].url; + const headers = options?.headers ?? {}; const timeout = options?.timeoutMs ?? 30_000; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeout); try { - const res = await fetch(url, { - headers: mergeHeaders(headers), - signal: ctrl.signal, - }); + const res = await fetch(url, { headers: mergeHeaders(headers), signal: ctrl.signal }); if (!res.ok) throw new Error(`HTTP ${res.status} fetching page ${pageIndex}`); const contentType = res.headers.get('content-type') ?? 'image/jpeg'; @@ -448,43 +380,31 @@ export async function downloadMangaPage( } } -/** - * Download an entire manga chapter as a `.zip` archive. - * Uses a minimal zero-dependency ZIP writer (STORE method — images are - * already compressed, so no deflation needed). - * - * @param pages - The `IMangaPayload` from `resolveStream` - * @param outputPath - Destination `.zip` file path - * @param options - Optional configuration - */ export async function downloadMangaChapter( - pages: IMangaPayload, + pages: Pages, outputPath: string, options?: DownloadMangaChapterOptions, ): Promise { - if (pages.imageUrls.length === 0) { + if (pages.pages.length === 0) { throw new Error('downloadMangaChapter: no pages to download'); } const dir = path.dirname(outputPath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - const headers = pages.headers ?? {}; + const headers = options?.headers ?? {}; const timeout = options?.timeoutMs ?? 30_000; const entries: ZipEntry[] = []; - for (let i = 0; i < pages.imageUrls.length; i++) { - options?.onProgress?.({ downloaded: i, total: pages.imageUrls.length }); + for (let i = 0; i < pages.pages.length; i++) { + options?.onProgress?.({ downloaded: i, total: pages.pages.length }); - const url = pages.imageUrls[i]; + const url = pages.pages[i].url; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeout); try { - const res = await fetch(url, { - headers: mergeHeaders(headers), - signal: ctrl.signal, - }); + const res = await fetch(url, { headers: mergeHeaders(headers), signal: ctrl.signal }); if (!res.ok) throw new Error(`HTTP ${res.status} fetching page ${i}`); const contentType = res.headers.get('content-type') ?? 'image/jpeg'; @@ -499,20 +419,15 @@ export async function downloadMangaChapter( } } - options?.onProgress?.({ downloaded: pages.imageUrls.length, total: pages.imageUrls.length }); + options?.onProgress?.({ downloaded: pages.pages.length, total: pages.pages.length }); const zipBuffer = createZipBuffer(entries); fs.writeFileSync(outputPath, zipBuffer); - return { - outputPath, - pageCount: entries.length, - fileSize: zipBuffer.length, - }; + return { outputPath, pageCount: entries.length, fileSize: zipBuffer.length }; } // ─── Minimal ZIP Writer (STORE, no compression) ───────────────────────────── -// Implements the ZIP spec just enough for uncompressed archives. // Images are already compressed (JPEG/PNG/WebP), so STORE is optimal. interface ZipEntry { @@ -520,17 +435,13 @@ interface ZipEntry { data: Buffer; } -/** CRC-32 lookup table. */ const crc32Table: number[] = []; for (let i = 0; i < 256; i++) { let c = i; - for (let j = 0; j < 8; j++) { - c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; - } + for (let j = 0; j < 8; j++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; crc32Table[i] = c; } -/** Compute CRC-32 for a buffer. */ export function crc32(buf: Buffer): number { let crc = 0xffffffff; for (let i = 0; i < buf.length; i++) { @@ -539,10 +450,6 @@ export function crc32(buf: Buffer): number { return (crc ^ 0xffffffff) >>> 0; } -/** - * Create a ZIP file buffer from an array of entries using the STORE method. - * No compression — perfect for already-compressed image data. - */ export function createZipBuffer(entries: ZipEntry[]): Buffer { const parts: Buffer[] = []; const centralDirEntries: Buffer[] = []; @@ -553,42 +460,40 @@ export function createZipBuffer(entries: ZipEntry[]): Buffer { const crcVal = crc32(entry.data); const size = entry.data.length; - // Local File Header (30 bytes + filename) const local = Buffer.alloc(30 + nameBytes.length); - local.writeUInt32LE(0x04034b50, 0); // Local file header signature - local.writeUInt16LE(20, 4); // Version needed to extract (2.0) - local.writeUInt16LE(0, 6); // General purpose bit flag - local.writeUInt16LE(0, 8); // Compression method: STORE - local.writeUInt16LE(0, 10); // Last mod file time - local.writeUInt16LE(0, 12); // Last mod file date - local.writeUInt32LE(crcVal, 14); // CRC-32 - local.writeUInt32LE(size, 18); // Compressed size - local.writeUInt32LE(size, 22); // Uncompressed size - local.writeUInt16LE(nameBytes.length, 26); // Filename length - local.writeUInt16LE(0, 28); // Extra field length + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0, 6); + local.writeUInt16LE(0, 8); + local.writeUInt16LE(0, 10); + local.writeUInt16LE(0, 12); + local.writeUInt32LE(crcVal, 14); + local.writeUInt32LE(size, 18); + local.writeUInt32LE(size, 22); + local.writeUInt16LE(nameBytes.length, 26); + local.writeUInt16LE(0, 28); nameBytes.copy(local, 30); parts.push(local, entry.data); - // Central Directory File Header (46 bytes + filename) const central = Buffer.alloc(46 + nameBytes.length); - central.writeUInt32LE(0x02014b50, 0); // Central directory signature - central.writeUInt16LE(20, 4); // Version made by - central.writeUInt16LE(20, 6); // Version needed - central.writeUInt16LE(0, 8); // General purpose bit flag - central.writeUInt16LE(0, 10); // Compression method: STORE - central.writeUInt16LE(0, 12); // Last mod file time - central.writeUInt16LE(0, 14); // Last mod file date - central.writeUInt32LE(crcVal, 16); // CRC-32 - central.writeUInt32LE(size, 20); // Compressed size - central.writeUInt32LE(size, 24); // Uncompressed size - central.writeUInt16LE(nameBytes.length, 28); // Filename length - central.writeUInt16LE(0, 30); // Extra field length - central.writeUInt16LE(0, 32); // File comment length - central.writeUInt16LE(0, 34); // Disk number start - central.writeUInt16LE(0, 36); // Internal file attributes - central.writeUInt32LE(0, 38); // External file attributes - central.writeUInt32LE(offset, 42); // Relative offset of local header + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0, 8); + central.writeUInt16LE(0, 10); + central.writeUInt16LE(0, 12); + central.writeUInt16LE(0, 14); + central.writeUInt32LE(crcVal, 16); + central.writeUInt32LE(size, 20); + central.writeUInt32LE(size, 24); + central.writeUInt16LE(nameBytes.length, 28); + central.writeUInt16LE(0, 30); + central.writeUInt16LE(0, 32); + central.writeUInt16LE(0, 34); + central.writeUInt16LE(0, 36); + central.writeUInt32LE(0, 38); + central.writeUInt32LE(offset, 42); nameBytes.copy(central, 46); centralDirEntries.push(central); @@ -600,16 +505,15 @@ export function createZipBuffer(entries: ZipEntry[]): Buffer { const centralDir = Buffer.concat(centralDirEntries); const centralDirSize = centralDir.length; - // End of Central Directory Record (22 bytes) const eocd = Buffer.alloc(22); - eocd.writeUInt32LE(0x06054b50, 0); // EOCD signature - eocd.writeUInt16LE(0, 4); // Disk number - eocd.writeUInt16LE(0, 6); // Disk with central directory - eocd.writeUInt16LE(entries.length, 8); // Entries on this disk - eocd.writeUInt16LE(entries.length, 10); // Total entries - eocd.writeUInt32LE(centralDirSize, 12); // Central directory size - eocd.writeUInt32LE(centralDirOffset, 16); // Central directory offset - eocd.writeUInt16LE(0, 20); // ZIP file comment length + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(0, 4); + eocd.writeUInt16LE(0, 6); + eocd.writeUInt16LE(entries.length, 8); + eocd.writeUInt16LE(entries.length, 10); + eocd.writeUInt32LE(centralDirSize, 12); + eocd.writeUInt32LE(centralDirOffset, 16); + eocd.writeUInt16LE(0, 20); parts.push(centralDir, eocd); return Buffer.concat(parts); diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..69592db --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,33 @@ +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/extractors/BaseExtractor.ts b/src/extractors/BaseExtractor.ts index 89b9e71..326c052 100644 --- a/src/extractors/BaseExtractor.ts +++ b/src/extractors/BaseExtractor.ts @@ -1,4 +1,4 @@ -import { HttpClient } from '../transport/http.js'; +import { HttpClient } from '../internal/http.js'; import { IVideoPayload } from '../types/index.js'; export abstract class BaseExtractor { diff --git a/src/health.ts b/src/health.ts new file mode 100644 index 0000000..e779450 --- /dev/null +++ b/src/health.ts @@ -0,0 +1,70 @@ +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/index.ts b/src/index.ts index 5d6d3cc..23b49f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,45 +1,32 @@ -// 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'; - -// 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'; - -// Utilities -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'; +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 { SdkOptions } from './config.js'; + +// ── Server ──────────────────────────────────────────────────────────────────── +export { startServer } from './server/index.js'; +export type { ServerOptions, ProxyOptions } from './server/index.js'; + +// ── Downloads ───────────────────────────────────────────────────────────────── +export { downloadVideo, downloadMangaPage, downloadMangaChapter } from './download/index.js'; +export type { + DownloadVideoOptions, + DownloadVideoResult, + DownloadMangaPageOptions, + DownloadMangaPageResult, + DownloadMangaChapterOptions, + DownloadMangaChapterResult, +} from './download/index.js'; diff --git a/src/transport/dom.ts b/src/internal/dom.ts similarity index 100% rename from src/transport/dom.ts rename to src/internal/dom.ts diff --git a/src/transport/hlsUtils.ts b/src/internal/hls.ts similarity index 100% rename from src/transport/hlsUtils.ts rename to src/internal/hls.ts diff --git a/src/transport/http.ts b/src/internal/http.ts similarity index 91% rename from src/transport/http.ts rename to src/internal/http.ts index 7866fed..b1669c8 100644 --- a/src/transport/http.ts +++ b/src/internal/http.ts @@ -98,6 +98,28 @@ export class HttpClient { return this.defaultHeaders; } + /** + * Return a new HttpClient that merges `headers` on top of this instance's + * defaults, sharing the same rate-limiter, transport, and retry config. + * Use this to give a source its own header set without forking the rate + * budget or creating a second transport. + */ + public withHeaders(headers: Record): HttpClient { + const clone = new HttpClient({ + proxyUrl: this.proxyUrl, + proxyType: this.proxyType, + proxyQueryParam: this.proxyQueryParam, + defaultHeaders: { ...this.defaultHeaders, ...headers }, + timeoutMs: this.timeoutMs, + transport: this.transport, + disableRateLimit: true, // we'll inject the shared limiter below + retry: this.retryConfig, + }); + // Share the parent's rate-limiter so all sources stay within one budget. + clone.rateLimiter = this.rateLimiter; + return clone; + } + public requestUrl(url: string): string { if (!this.proxyUrl) return url; if (this.proxyType === 'prepend') { diff --git a/src/internal/id.ts b/src/internal/id.ts new file mode 100644 index 0000000..48292bb --- /dev/null +++ b/src/internal/id.ts @@ -0,0 +1,34 @@ +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 }); + } +} diff --git a/src/transport/rateLimiter.ts b/src/internal/rateLimiter.ts similarity index 100% rename from src/transport/rateLimiter.ts rename to src/internal/rateLimiter.ts diff --git a/src/transport/retry.ts b/src/internal/retry.ts similarity index 100% rename from src/transport/retry.ts rename to src/internal/retry.ts diff --git a/src/meta/similarity.ts b/src/internal/similarity.ts similarity index 100% rename from src/meta/similarity.ts rename to src/internal/similarity.ts diff --git a/src/transport/transport.ts b/src/internal/transport.ts similarity index 100% rename from src/transport/transport.ts rename to src/internal/transport.ts diff --git a/src/meta/AnilistMeta.ts b/src/meta/AnilistMeta.ts deleted file mode 100644 index dde7056..0000000 --- a/src/meta/AnilistMeta.ts +++ /dev/null @@ -1,715 +0,0 @@ -import { HttpClient } from '../transport/http.js'; -import { - CallOptions, - IMediaCharacter, - IMediaExternalLink, - IMediaMetadata, - IMediaRecommendation, - IMediaRelation, - IMediaStaff, - IMetaSearchResult, - IStreamingEpisode, - MediaCatalogType, - MediaFormat, - MediaRelationType, - MediaSeason, - MediaStatus, -} from '../types/index.js'; -import { buildUrn } from '../utils/urn.js'; -import { - BaseMetadataProvider, - BaseMetadataProviderOptions, - BrowseKind, - BrowseOptions, -} from './BaseMetadataProvider.js'; - -const ANILIST_API = 'https://graphql.anilist.co'; - -/** - * AniList GraphQL metadata provider. - * - * No API key required. We hit the public `https://graphql.anilist.co` - * endpoint with a small, fixed query. The free-tier rate limit (90 req/min - * per IP) is generous enough that we don't add an internal throttle — - * consumers who need higher throughput should pass a shared `SdkCache` via - * the meta server routes. - */ -export interface AnilistMetaOptions extends BaseMetadataProviderOptions { - /** - * Override the GraphQL endpoint. Useful for self-hosted mirrors or for - * pointing at a local mock during tests. - */ - apiUrl?: string; -} - -export class AnilistMeta extends BaseMetadataProvider { - public readonly id = 'anilist'; - public readonly supportedTypes: MediaCatalogType[] = ['ANIME', 'MANGA']; - private apiUrl: string; - - constructor(http: HttpClient, options: AnilistMetaOptions = {}) { - super(http, options); - this.apiUrl = options.apiUrl ?? ANILIST_API; - } - - public override supportsBrowseKind(kind: BrowseKind): boolean { - return kind === 'trending' || kind === 'popular' || kind === 'seasonal' || kind === 'top'; - } - - protected override async browseRawNative( - kind: BrowseKind, - options: BrowseOptions, - ): Promise { - const catalogType = options.catalogType ?? 'ANIME'; - const type = catalogType === 'MANGA' ? 'MANGA' : 'ANIME'; - const sort = browseSort(kind); - if (kind === 'seasonal' && (!options.season || !options.year)) { - throw new Error('AniList browse(seasonal): season and year are required'); - } - const gql = /* GraphQL */ ` - query ( - $page: Int - $perPage: Int - $type: MediaType - $sort: [MediaSort] - $season: MediaSeason - $seasonYear: Int - $format: MediaFormat - ) { - Page(page: $page, perPage: $perPage) { - media( - type: $type - sort: $sort - season: $season - seasonYear: $seasonYear - format: $format - ) { - id - type - format - seasonYear - startDate { - year - } - averageScore - isAdult - idMal - title { - romaji - english - native - userPreferred - } - coverImage { - extraLarge - large - medium - color - } - } - } - } - `; - const variables: Record = { - page: options.page ?? 1, - perPage: Math.min(options.perPage ?? 20, 50), - type, - sort, - }; - if (kind === 'seasonal') { - variables.season = options.season; - variables.seasonYear = options.year; - } - if (options.format) variables.format = options.format; - - const res = await this.http.post( - this.apiUrl, - { query: gql, variables }, - { signal: options.signal }, - ); - if (res.status !== 200) { - throw new Error(`AniList browse failed with status ${res.status}`); - } - const json = (await res.json()) as any; - const media: any[] = json?.data?.Page?.media ?? []; - return media.map((m) => ({ - id: String(m.id), - providerId: this.id, - catalogType: anilistTypeToCatalog(m.type), - title: { - romaji: m.title?.romaji ?? undefined, - english: m.title?.english ?? undefined, - native: m.title?.native ?? undefined, - userPreferred: m.title?.userPreferred ?? undefined, - }, - cover: m.coverImage - ? { - large: m.coverImage.extraLarge ?? m.coverImage.large ?? undefined, - medium: m.coverImage.medium ?? undefined, - color: m.coverImage.color ?? undefined, - } - : undefined, - year: m.seasonYear ?? m.startDate?.year ?? undefined, - format: anilistFormat(m.format), - score: typeof m.averageScore === 'number' ? m.averageScore : undefined, - isAdult: !!m.isAdult, - mappings: { anilist: m.id, mal: m.idMal ?? undefined }, - })); - } - - protected async searchRawNative( - query: string, - options: CallOptions = {}, - ): Promise { - const gql = /* GraphQL */ ` - query ($q: String, $perPage: Int) { - Page(page: 1, perPage: $perPage) { - media(search: $q, sort: SEARCH_MATCH) { - id - type - format - seasonYear - startDate { - year - } - averageScore - isAdult - idMal - title { - romaji - english - native - userPreferred - } - coverImage { - extraLarge - large - medium - color - } - } - } - } - `; - const res = await this.http.post( - this.apiUrl, - { query: gql, variables: { q: query, perPage: 25 } }, - { signal: options.signal }, - ); - if (res.status !== 200) { - throw new Error(`AniList search failed with status ${res.status}`); - } - const json = (await res.json()) as any; - const media: any[] = json?.data?.Page?.media ?? []; - return media.map((m) => ({ - id: String(m.id), - providerId: this.id, - catalogType: anilistTypeToCatalog(m.type), - title: { - romaji: m.title?.romaji ?? undefined, - english: m.title?.english ?? undefined, - native: m.title?.native ?? undefined, - userPreferred: m.title?.userPreferred ?? undefined, - }, - cover: m.coverImage - ? { - large: m.coverImage.extraLarge ?? m.coverImage.large ?? undefined, - medium: m.coverImage.medium ?? undefined, - color: m.coverImage.color ?? undefined, - } - : undefined, - year: m.seasonYear ?? m.startDate?.year ?? undefined, - format: anilistFormat(m.format), - score: typeof m.averageScore === 'number' ? m.averageScore : undefined, - isAdult: !!m.isAdult, - mappings: { - anilist: m.id, - mal: m.idMal ?? undefined, - }, - })); - } - - protected async fetchMediaInfoRawNative( - nativeId: string, - options: CallOptions = {}, - ): Promise { - const id = Number(nativeId); - if (!Number.isFinite(id)) { - throw new Error(`Invalid AniList ID: ${nativeId}`); - } - const gql = /* GraphQL */ ` - query ($id: Int) { - Media(id: $id) { - id - type - format - status - episodes - chapters - duration - season - seasonYear - startDate { - year - month - day - } - endDate { - year - month - day - } - averageScore - isAdult - idMal - description(asHtml: false) - synonyms - genres - studios(isMain: true) { - nodes { - name - } - } - tags { - name - rank - } - title { - romaji - english - native - userPreferred - } - coverImage { - extraLarge - large - medium - color - } - bannerImage - trailer { - id - site - } - externalLinks { - site - url - language - type - } - streamingEpisodes { - title - thumbnail - url - site - } - relations { - edges { - relationType(version: 2) - node { - id - type - format - status - title { - romaji - english - native - userPreferred - } - coverImage { - extraLarge - large - medium - color - } - } - } - } - characters(sort: [ROLE, RELEVANCE, ID], perPage: 25) { - edges { - role - node { - id - name { - full - native - } - image { - large - medium - } - } - voiceActors(sort: [RELEVANCE, ID]) { - id - name { - full - native - } - language: languageV2 - image { - large - medium - } - } - } - } - staff(sort: [RELEVANCE, ID], perPage: 25) { - edges { - role - node { - id - name { - full - native - } - image { - large - medium - } - } - } - } - recommendations(sort: [RATING_DESC], perPage: 12) { - nodes { - rating - mediaRecommendation { - id - type - format - title { - romaji - english - native - userPreferred - } - coverImage { - extraLarge - large - medium - color - } - } - } - } - } - } - `; - const res = await this.http.post( - this.apiUrl, - { query: gql, variables: { id } }, - { signal: options.signal }, - ); - if (res.status !== 200) { - throw new Error(`AniList fetchMediaInfo failed with status ${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}`); - - const trailerUrl = - m.trailer && m.trailer.site === 'youtube' - ? `https://www.youtube.com/watch?v=${m.trailer.id}` - : undefined; - - return { - id: String(m.id), - providerId: this.id, - catalogType: anilistTypeToCatalog(m.type), - title: { - romaji: m.title?.romaji ?? undefined, - english: m.title?.english ?? undefined, - native: m.title?.native ?? undefined, - userPreferred: m.title?.userPreferred ?? undefined, - }, - description: m.description ?? undefined, - cover: m.coverImage - ? { - large: m.coverImage.extraLarge ?? m.coverImage.large ?? undefined, - medium: m.coverImage.medium ?? undefined, - color: m.coverImage.color ?? undefined, - } - : undefined, - banner: m.bannerImage ?? undefined, - status: anilistStatus(m.status), - format: anilistFormat(m.format), - episodeCount: m.episodes ?? undefined, - chapterCount: m.chapters ?? undefined, - durationMinutes: m.duration ?? undefined, - genres: m.genres ?? undefined, - tags: - Array.isArray(m.tags) && m.tags.length > 0 - ? m.tags.map((t: any) => t.name).filter(Boolean) - : undefined, - studios: - m.studios?.nodes && m.studios.nodes.length > 0 - ? m.studios.nodes.map((n: any) => n.name).filter(Boolean) - : undefined, - year: m.seasonYear ?? m.startDate?.year ?? undefined, - season: anilistSeason(m.season), - startDate: formatDate(m.startDate), - endDate: formatDate(m.endDate), - score: typeof m.averageScore === 'number' ? m.averageScore : undefined, - trailer: trailerUrl, - isAdult: !!m.isAdult, - synonyms: Array.isArray(m.synonyms) ? m.synonyms.filter(Boolean) : undefined, - mappings: { - anilist: m.id, - mal: m.idMal ?? undefined, - }, - relations: this.mapRelations(m.relations?.edges), - characters: this.mapCharacters(m.characters?.edges), - staff: this.mapStaff(m.staff?.edges), - recommendations: this.mapRecommendations(m.recommendations?.nodes), - externalLinks: this.mapExternalLinks(m.externalLinks), - streamingEpisodes: this.mapStreamingEpisodes(m.streamingEpisodes), - }; - } - - // ── AniList enrichment mappers ────────────────────────────────────────── - - private mapRelations(edges: unknown): IMediaRelation[] | undefined { - if (!Array.isArray(edges) || edges.length === 0) return undefined; - const out: IMediaRelation[] = []; - for (const e of edges) { - const node = (e as any)?.node; - if (!node?.id) continue; - out.push({ - id: buildUrn(this.id, String(node.id)), - relationType: anilistRelationType((e as any).relationType), - catalogType: anilistTypeToCatalog(node.type), - format: anilistFormat(node.format), - status: anilistStatus(node.status), - title: { - romaji: node.title?.romaji ?? undefined, - english: node.title?.english ?? undefined, - native: node.title?.native ?? undefined, - userPreferred: node.title?.userPreferred ?? undefined, - }, - cover: node.coverImage - ? { - large: node.coverImage.extraLarge ?? node.coverImage.large ?? undefined, - medium: node.coverImage.medium ?? undefined, - color: node.coverImage.color ?? undefined, - } - : undefined, - }); - } - return out.length > 0 ? out : undefined; - } - - private mapCharacters(edges: unknown): IMediaCharacter[] | undefined { - if (!Array.isArray(edges) || edges.length === 0) return undefined; - const out: IMediaCharacter[] = []; - for (const e of edges) { - const node = (e as any)?.node; - if (!node?.id) continue; - out.push({ - id: buildUrn(this.id, `character:${node.id}`), - name: node.name?.full ?? node.name?.native ?? '', - role: (e as any).role ?? undefined, - image: node.image - ? { large: node.image.large ?? undefined, medium: node.image.medium ?? undefined } - : undefined, - voiceActors: Array.isArray((e as any).voiceActors) - ? (e as any).voiceActors.slice(0, 5).map((va: any) => ({ - id: buildUrn(this.id, `staff:${va.id}`), - name: va.name?.full ?? va.name?.native ?? '', - language: va.language ?? undefined, - image: va.image - ? { large: va.image.large ?? undefined, medium: va.image.medium ?? undefined } - : undefined, - })) - : undefined, - }); - } - return out.length > 0 ? out : undefined; - } - - private mapStaff(edges: unknown): IMediaStaff[] | undefined { - if (!Array.isArray(edges) || edges.length === 0) return undefined; - const out: IMediaStaff[] = []; - for (const e of edges) { - const node = (e as any)?.node; - if (!node?.id) continue; - out.push({ - id: buildUrn(this.id, `staff:${node.id}`), - name: node.name?.full ?? node.name?.native ?? '', - role: (e as any).role ?? undefined, - image: node.image - ? { large: node.image.large ?? undefined, medium: node.image.medium ?? undefined } - : undefined, - }); - } - return out.length > 0 ? out : undefined; - } - - private mapRecommendations(nodes: unknown): IMediaRecommendation[] | undefined { - if (!Array.isArray(nodes) || nodes.length === 0) return undefined; - const out: IMediaRecommendation[] = []; - for (const n of nodes) { - const rec = (n as any)?.mediaRecommendation; - if (!rec?.id) continue; - out.push({ - id: buildUrn(this.id, String(rec.id)), - catalogType: anilistTypeToCatalog(rec.type), - format: anilistFormat(rec.format), - title: { - romaji: rec.title?.romaji ?? undefined, - english: rec.title?.english ?? undefined, - native: rec.title?.native ?? undefined, - userPreferred: rec.title?.userPreferred ?? undefined, - }, - cover: rec.coverImage - ? { - large: rec.coverImage.extraLarge ?? rec.coverImage.large ?? undefined, - medium: rec.coverImage.medium ?? undefined, - color: rec.coverImage.color ?? undefined, - } - : undefined, - rating: typeof (n as any).rating === 'number' ? (n as any).rating : undefined, - }); - } - return out.length > 0 ? out : undefined; - } - - private mapExternalLinks(links: unknown): IMediaExternalLink[] | undefined { - if (!Array.isArray(links) || links.length === 0) return undefined; - const out: IMediaExternalLink[] = []; - for (const l of links) { - const site = (l as any)?.site; - const url = (l as any)?.url; - if (!site || !url) continue; - out.push({ - site, - url, - language: (l as any).language ?? undefined, - type: anilistExternalLinkType((l as any).type), - }); - } - return out.length > 0 ? out : undefined; - } - - private mapStreamingEpisodes(eps: unknown): IStreamingEpisode[] | undefined { - if (!Array.isArray(eps) || eps.length === 0) return undefined; - const out: IStreamingEpisode[] = []; - for (const ep of eps) { - const title = (ep as any)?.title as string | undefined; - if (!title) continue; - // AniList streamingEpisodes encodes episode number in the title prefix - // like "Episode 1 - Romance Dawn". Parse it; fall through silently - // when the format is unfamiliar. - const numMatch = title.match(/^Episode\s+(\d+(?:\.\d+)?)\b/i); - const number = numMatch ? parseFloat(numMatch[1]) : NaN; - if (!Number.isFinite(number)) continue; - const cleanTitle = title.replace(/^Episode\s+\d+(?:\.\d+)?\s*[-—–:]?\s*/i, '').trim(); - out.push({ - number, - title: cleanTitle || undefined, - thumbnail: (ep as any).thumbnail ?? undefined, - externalUrl: (ep as any).url ?? undefined, - }); - } - return out.length > 0 ? out : undefined; - } -} - -function anilistRelationType(s: unknown): MediaRelationType { - if (typeof s !== 'string') return 'OTHER'; - switch (s) { - case 'SEQUEL': - case 'PREQUEL': - case 'PARENT': - case 'SIDE_STORY': - case 'SPIN_OFF': - case 'ADAPTATION': - case 'CHARACTER': - case 'SUMMARY': - case 'COMPILATION': - case 'CONTAINS': - case 'OTHER': - return s as MediaRelationType; - case 'ALTERNATIVE': - return 'ALTERNATIVE'; - case 'CHILD': - return 'CHILD'; - default: - return 'OTHER'; - } -} - -function browseSort(kind: BrowseKind): string[] { - switch (kind) { - case 'trending': - return ['TRENDING_DESC', 'POPULARITY_DESC']; - case 'popular': - return ['POPULARITY_DESC']; - case 'seasonal': - return ['POPULARITY_DESC']; - case 'top': - return ['SCORE_DESC']; - } -} - -function anilistExternalLinkType(t: unknown): IMediaExternalLink['type'] | undefined { - if (typeof t !== 'string') return undefined; - if (t === 'STREAMING') return 'STREAMING'; - if (t === 'INFO') return 'INFO'; - if (t === 'SOCIAL') return 'SOCIAL'; - return undefined; -} - -function anilistTypeToCatalog(t: unknown): MediaCatalogType { - return t === 'MANGA' ? 'MANGA' : 'ANIME'; -} - -function anilistStatus(s: unknown): MediaStatus | undefined { - if (!s || typeof s !== 'string') return undefined; - // AniList uses the same constant names we do. - if ( - s === 'FINISHED' || - s === 'RELEASING' || - s === 'NOT_YET_RELEASED' || - s === 'CANCELLED' || - s === 'HIATUS' - ) { - return s; - } - return 'UNKNOWN'; -} - -function anilistFormat(f: unknown): MediaFormat | undefined { - if (!f || typeof f !== 'string') return undefined; - switch (f) { - case 'TV': - case 'TV_SHORT': - case 'MOVIE': - case 'SPECIAL': - case 'OVA': - case 'ONA': - case 'MUSIC': - case 'MANGA': - case 'NOVEL': - case 'ONE_SHOT': - return f as MediaFormat; - default: - return 'UNKNOWN'; - } -} - -function anilistSeason(s: unknown): MediaSeason | undefined { - if (s === 'WINTER' || s === 'SPRING' || s === 'SUMMER' || s === 'FALL') return s; - return undefined; -} - -function formatDate(d: unknown): string | undefined { - if (!d || typeof d !== 'object') return undefined; - const o = d as { year?: number; month?: number; day?: number }; - if (!o.year) return undefined; - const y = String(o.year).padStart(4, '0'); - const m = o.month ? String(o.month).padStart(2, '0') : undefined; - const day = o.day ? String(o.day).padStart(2, '0') : undefined; - if (m && day) return `${y}-${m}-${day}`; - if (m) return `${y}-${m}`; - return y; -} diff --git a/src/meta/BaseMetadataProvider.ts b/src/meta/BaseMetadataProvider.ts deleted file mode 100644 index e94cc61..0000000 --- a/src/meta/BaseMetadataProvider.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { HttpClient } from '../transport/http.js'; -import { BaseProvider } from '../providers/BaseProvider.js'; -import { - CallOptions, - ContentLanguage, - IContentUnit, - IMediaMetadata, - IMetaSearchResult, - IStreamingEpisode, - IUnitTracks, - MediaCatalogType, - MediaFormat, - MediaSeason, - ResolvedMediaStream, - Urn, -} from '../types/index.js'; - -import { buildUrn, unwrapUrn } from '../utils/urn.js'; -import { MappingClient } from './MappingClient.js'; - -/** - * Named browse buckets for {@link BaseMetadataProvider.browse}. - */ -export type BrowseKind = 'trending' | 'popular' | 'seasonal' | 'top'; - -export interface BrowseOptions extends CallOptions { - /** Anime / manga discriminator; defaults to ANIME. */ - catalogType?: MediaCatalogType; - /** 1-based page number. Default 1. */ - page?: number; - /** Page size. Default 20, capped per-catalogue. */ - perPage?: number; - /** Required for `kind: 'seasonal'`. */ - season?: MediaSeason; - /** Required for `kind: 'seasonal'`. */ - year?: number; - /** Optional format filter (TV, MOVIE, …). */ - format?: MediaFormat; -} - -/** - * @deprecated Use {@link CallOptions} from `src/types/index.ts`. Kept as - * an alias so existing callers continue to compile. - */ -export type MetaCallOptions = CallOptions; - -export interface BaseMetadataProviderOptions { - /** - * Shared MappingClient instance. Inject your own to share its cache (and - * MALSync rate-limit budget) across multiple meta providers in the same - * process. A fresh instance is created if omitted. - */ - mappingClient?: MappingClient; -} - -/** - * Common surface every metadata provider implements. - * - * Concrete implementations (AniList, MAL, Kitsu) supply: - * - `searchRaw(query)` — full-text search against the upstream catalogue. - * - `fetchMediaInfoRaw(nativeId)` — return full metadata by native ID. - * - * Everything else (resolving a content provider's `mediaId`, listing - * episodes, getting a playable stream) is delegated to a {@link BaseProvider} - * that the caller picks at call time. This is the swap-out point: the same - * metadata record can drive `AllmangaProvider`, `GogoanimeProvider`, etc., - * and the meta layer never needs to know which one. - * - * The "Urn" public id space is `${this.id}:${nativeId}` (e.g. `anilist:21`). - */ -export abstract class BaseMetadataProvider { - abstract readonly id: string; - abstract readonly supportedTypes: MediaCatalogType[]; - - protected mapping: MappingClient; - - constructor( - protected http: HttpClient, - options: BaseMetadataProviderOptions = {}, - ) { - this.mapping = options.mappingClient ?? new MappingClient(http); - } - - // ── Native catalogue surface (subclasses implement) ────────────────────── - - protected abstract searchRawNative( - query: string, - options?: CallOptions, - ): Promise; - protected abstract fetchMediaInfoRawNative( - nativeId: string, - options?: CallOptions, - ): Promise; - - /** - * Optional browse endpoints. Subclasses override per-catalogue. - * - * `browse(kind, options)` returns a paginated list of titles in one of - * the named buckets: - * - `'trending'` — currently surfaced by the catalogue's trending shelf - * - `'popular'` — all-time popular titles - * - `'seasonal'` — titles airing in `options.season` + `options.year` - * - `'top'` — highest-scored titles - * - * Default implementation throws — set `supportsBrowse[kind]` on your - * provider when you can implement a bucket. - */ - public supportsBrowseKind(_kind: BrowseKind): boolean { - return false; - } - - public async browse(kind: BrowseKind, options: BrowseOptions = {}): Promise { - if (!this.supportsBrowseKind(kind)) { - throw new Error(`${this.id}: browse('${kind}') not supported by this provider`); - } - const items = await this.browseRawNative(kind, options); - return items.map((r) => ({ ...r, id: buildUrn(this.id, r.id), providerId: this.id })); - } - - /** - * Subclasses override this to actually serve a browse request. Default - * throws. - */ - protected browseRawNative( - _kind: BrowseKind, - _options: BrowseOptions, - ): Promise { - throw new Error(`${this.id}: browseRawNative is not implemented`); - } - - // ── Public API ─────────────────────────────────────────────────────────── - - public async search(query: string, options: CallOptions = {}): Promise { - const results = await this.searchRawNative(query, options); - return results.map((r) => ({ ...r, id: buildUrn(this.id, r.id), providerId: this.id })); - } - - public async fetchMediaInfo(metaUrn: Urn, options: CallOptions = {}): Promise { - const raw = unwrapUrn(this.id, metaUrn); - const meta = await this.fetchMediaInfoRawNative(raw, options); - return { ...meta, id: buildUrn(this.id, meta.id), providerId: this.id }; - } - - /** - * List episodes/chapters on `contentProvider` for the title identified by - * `metaUrn`. Resolves the cross-provider mapping under the hood, then - * merges any per-episode enrichment the meta record carries (titles, - * thumbnails, filler markers). - */ - public async fetchContentUnits( - metaUrn: Urn, - contentProvider: BaseProvider, - options: CallOptions = {}, - ): Promise { - const metadata = await this.fetchMediaInfo(metaUrn, options); - const resolution = await this.mapping.resolveProviderMediaId( - metadata, - contentProvider, - options, - ); - if (!resolution) { - throw new Error( - `No match for "${metadata.title.userPreferred ?? metadata.title.romaji ?? metaUrn}" on provider "${contentProvider.id}"`, - ); - } - const units = await contentProvider.fetchContentUnits( - buildUrn(contentProvider.id, resolution.rawMediaId), - options, - ); - return this.enrichContentUnits(units, metadata); - } - - /** - * Resolve a stream by metadata + episode number on the given content - * provider. Picks the unit whose `number` matches `episodeNumber`. - * - * The episode-number index keeps the caller's mental model anchored to the - * metadata catalogue (which uses 1..episodeCount) rather than each content - * provider's quirky internal IDs. - */ - public async resolveStream( - metaUrn: Urn, - episodeNumber: number, - contentProvider: BaseProvider, - language?: ContentLanguage, - options: CallOptions = {}, - ): Promise { - const unit = await this.findContentUnit(metaUrn, episodeNumber, contentProvider, options); - return contentProvider.resolveStream(unit.id, language, options); - } - - /** Same selection logic as `resolveStream`, but for the cheap-tracks path. */ - public async fetchUnitTracks( - metaUrn: Urn, - episodeNumber: number, - contentProvider: BaseProvider, - language?: ContentLanguage, - options: CallOptions = {}, - ): Promise { - if (!contentProvider.supportsUnitTracks) { - throw new Error(`Provider "${contentProvider.id}" does not support fetchUnitTracks`); - } - const unit = await this.findContentUnit(metaUrn, episodeNumber, contentProvider, options); - return contentProvider.fetchUnitTracks(unit.id, language, options); - } - - /** - * Surface the underlying mapping result. Useful when callers want to log - * which content-provider title was picked, or stash the raw ID for - * out-of-band use. - */ - public async resolveContentProviderMediaId( - metaUrn: Urn, - contentProvider: BaseProvider, - options: CallOptions = {}, - ): Promise<{ rawMediaId: string; mediaUrn: Urn; matchedTitle: string }> { - const metadata = await this.fetchMediaInfo(metaUrn, options); - const resolution = await this.mapping.resolveProviderMediaId( - metadata, - contentProvider, - options, - ); - if (!resolution) { - throw new Error( - `No match for "${metadata.title.userPreferred ?? metadata.title.romaji ?? metaUrn}" on provider "${contentProvider.id}"`, - ); - } - return { - rawMediaId: resolution.rawMediaId, - mediaUrn: buildUrn(contentProvider.id, resolution.rawMediaId), - matchedTitle: resolution.matchedTitle, - }; - } - - // ── Hooks for subclasses ───────────────────────────────────────────────── - - /** - * Merge per-episode metadata onto the content provider's unit list. - * - * The default implementation looks for `metadata.streamingEpisodes` - * (AniList carries this — title, thumbnail, source URL per episode) and - * keys by episode number. Subclasses can override to fold in catalogue- - * specific fields (e.g. filler markers, recap flags). - */ - protected enrichContentUnits(units: IContentUnit[], metadata: IMediaMetadata): IContentUnit[] { - const byNumber = new Map(); - for (const ep of metadata.streamingEpisodes ?? []) { - if (typeof ep.number === 'number') byNumber.set(ep.number, ep); - } - if (byNumber.size === 0) return units; - return units.map((u) => { - const ext = byNumber.get(u.number); - if (!ext) return u; - return { - ...u, - // Prefer extended title only when meaningfully different (content - // provider's "Episode 5" is barren; meta's "The Plan to Defeat …" is - // what we actually want to surface). - title: ext.title ?? u.title, - ...(ext.thumbnail ? { thumbnailUrl: ext.thumbnail } : {}), - ...(ext.description ? { description: ext.description } : {}), - ...(typeof ext.isFiller === 'boolean' ? { isFiller: ext.isFiller } : {}), - ...(typeof ext.isRecap === 'boolean' ? { isRecap: ext.isRecap } : {}), - ...(ext.airDate ? { airDate: ext.airDate } : {}), - }; - }); - } - - /** - * Compute the absolute-episode offset for `metaUrn`. - * - * Many anime catalogues (notably AniList) split multi-season shows into - * separate entries (e.g. *Attack on Titan* S1, S2, S3, Final S). Many - * content providers, by contrast, list episodes as one continuous run - * (1..N). When that mismatch shows up, we walk the PREQUEL relation - * chain back, summing each prequel's `episodeCount`, and the resulting - * sum is the offset we add to a per-season episode number to get the - * absolute one. - * - * Public so callers building custom episode pickers can opt in directly. - * Cap of 8 hops prevents pathological cycles. - */ - public async computeAbsoluteEpisodeOffset( - metaUrn: Urn, - options: CallOptions = {}, - ): Promise { - let offset = 0; - let current: IMediaMetadata | undefined = await this.fetchMediaInfo(metaUrn, options); - const visited = new Set(); - for (let i = 0; i < 8 && current; i += 1) { - if (visited.has(current.id)) break; - visited.add(current.id); - const prequel = current.relations?.find((r) => r.relationType === 'PREQUEL'); - if (!prequel) break; - try { - const prevMeta = await this.fetchMediaInfo(prequel.id, options); - if (typeof prevMeta.episodeCount === 'number') offset += prevMeta.episodeCount; - current = prevMeta; - } catch { - break; - } - } - return offset; - } - - // ── Helpers ────────────────────────────────────────────────────────────── - - private async findContentUnit( - metaUrn: Urn, - episodeNumber: number, - contentProvider: BaseProvider, - options: CallOptions, - ): Promise { - const units = await this.fetchContentUnits(metaUrn, contentProvider, options); - const target = units.find((u) => u.number === episodeNumber); - if (target) return target; - - if (options.strictEpisodeMatching) { - throw new Error( - `Episode ${episodeNumber} not found on provider "${contentProvider.id}" (have: ${units.map((u) => u.number).join(', ')})`, - ); - } - - // ── Absolute-episode rescue ─────────────────────────────────────────── - // When the meta record describes only this season but the content - // provider's list runs across the whole series, look up the offset by - // summing previous seasons' episode counts and retry. We only trigger - // when (a) `episodeAbsoluteMatching` is opted in or set to 'auto', AND - // (b) the heuristic conditions for a multi-season concatenation are met. - const mode = options.episodeAbsoluteMatching ?? 'auto'; - if (mode !== 'never' && shouldTryAbsolute(units, episodeNumber, mode)) { - try { - const offset = await this.computeAbsoluteEpisodeOffset(metaUrn, options); - if (offset > 0) { - const absolute = episodeNumber + offset; - const absHit = units.find((u) => u.number === absolute); - if (absHit) return absHit; - } - } catch { - /* fall through to closest-below */ - } - } - - // Non-strict mode: fall back to closest-by-number for catalogues whose - // numbering is off-by-one (specials/prologues). Prefer ≤ over ≥ to - // avoid silently leaking *future* episodes when a number is missing - // in the middle. - const sorted = [...units].sort((a, b) => a.number - b.number); - const lower = [...sorted].reverse().find((u) => u.number <= episodeNumber); - if (lower) return lower; - - throw new Error( - `Episode ${episodeNumber} not found on provider "${contentProvider.id}" (have: ${units.map((u) => u.number).join(', ')})`, - ); - } -} - -/** - * Heuristic gate for the absolute-episode lookup. Cheap enough to run on - * every miss in 'auto' mode without measurable cost. - */ -function shouldTryAbsolute( - units: IContentUnit[], - requestedNumber: number, - mode: NonNullable, -): boolean { - if (mode === 'always') return true; - if (units.length === 0) return false; - const max = Math.max(...units.map((u) => u.number)); - // The requested ep is well within the content provider's range — looks - // like a real miss (specials, gaps), not a season-numbering mismatch. - if (requestedNumber <= max) return false; - // The miss is *above* the provider's max episode — only worth re-trying - // with an offset if the provider has > 1.5× the requested number of - // episodes, indicating a multi-season concatenation. - return max >= requestedNumber * 1.5; -} diff --git a/src/meta/KitsuMeta.ts b/src/meta/KitsuMeta.ts deleted file mode 100644 index 2a3019c..0000000 --- a/src/meta/KitsuMeta.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { HttpClient } from '../transport/http.js'; -import { - CallOptions, - IMediaMetadata, - IMetaSearchResult, - MediaCatalogType, - MediaFormat, - MediaSeason, - MediaStatus, -} from '../types/index.js'; -import { BaseMetadataProvider, BaseMetadataProviderOptions } from './BaseMetadataProvider.js'; - -/** - * Kitsu metadata provider (JSON:API). - * - * Kitsu has smaller catalogue coverage than AniList/MAL but a fast, - * keyless JSON:API at `https://kitsu.io/api/edge`. We use it as a - * tertiary option — primarily useful when callers already have Kitsu IDs. - * - * Native IDs are Kitsu IDs (numeric strings). - */ -export interface KitsuMetaOptions extends BaseMetadataProviderOptions { - apiUrl?: string; - defaultSearchType?: 'ANIME' | 'MANGA'; -} - -const KITSU_API = 'https://kitsu.io/api/edge'; - -export class KitsuMeta extends BaseMetadataProvider { - public readonly id = 'kitsu'; - public readonly supportedTypes: MediaCatalogType[] = ['ANIME', 'MANGA']; - private apiUrl: string; - private defaultSearchType: 'ANIME' | 'MANGA'; - - constructor(http: HttpClient, options: KitsuMetaOptions = {}) { - super(http, options); - this.apiUrl = options.apiUrl ?? KITSU_API; - this.defaultSearchType = options.defaultSearchType ?? 'ANIME'; - } - - protected async searchRawNative( - query: string, - options: CallOptions = {}, - ): Promise { - const path = this.defaultSearchType === '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: options.signal, - }); - if (res.status !== 200) { - throw new Error(`Kitsu search failed with status ${res.status}`); - } - const json = (await res.json()) as any; - const data: any[] = json?.data ?? []; - return data.map((r) => mapToSearchResult(r, path === 'manga' ? 'MANGA' : 'ANIME', path)); - } - - protected async fetchMediaInfoRawNative( - nativeId: string, - options: CallOptions = {}, - ): Promise { - // Accept typed (`anime:11013` / `manga:13`) or bare (`11013`) inputs. - let path: 'anime' | 'manga' = 'anime'; - let rawId = nativeId; - const sep = nativeId.indexOf(':'); - if (sep >= 0) { - const prefix = nativeId.slice(0, sep); - if (prefix === 'anime' || prefix === 'manga') { - path = prefix; - rawId = nativeId.slice(sep + 1); - } - } - let url = `${this.apiUrl}/${path}/${rawId}?include=genres,categories,mappings,animeProductions.producer`; - let res = await this.http.get(url, { - headers: { Accept: 'application/vnd.api+json' }, - signal: options.signal, - }); - if (res.status === 404 && sep < 0) { - path = 'manga'; - url = `${this.apiUrl}/${path}/${rawId}?include=genres,categories,mappings`; - res = await this.http.get(url, { - headers: { Accept: 'application/vnd.api+json' }, - signal: options.signal, - }); - } - if (res.status !== 200) { - throw new Error(`Kitsu fetchMediaInfo failed with status ${res.status}`); - } - const json = (await res.json()) as any; - const r = json?.data; - if (!r) throw new Error(`Kitsu: no media for id ${nativeId}`); - - const included: any[] = json?.included ?? []; - const genres = pickNames(included, r.relationships?.genres?.data, 'genres'); - const tags = pickNames(included, r.relationships?.categories?.data, 'categories'); - const studios = pickProducers(included, r.relationships?.animeProductions?.data); - const mappings = pickMappings(included, r.relationships?.mappings?.data); - - const a = r.attributes ?? {}; - const catalog = path === 'manga' ? 'MANGA' : 'ANIME'; - return { - id: `${path}:${r.id}`, - providerId: this.id, - catalogType: catalog, - title: kitsuTitles(a), - description: a.synopsis ?? a.description ?? undefined, - cover: a.posterImage - ? { - large: a.posterImage.large ?? a.posterImage.original ?? undefined, - medium: a.posterImage.medium ?? undefined, - small: a.posterImage.small ?? undefined, - } - : undefined, - banner: a.coverImage?.large ?? a.coverImage?.original ?? undefined, - status: kitsuStatus(a.status), - format: kitsuFormat(a.subtype), - episodeCount: a.episodeCount ?? undefined, - chapterCount: a.chapterCount ?? undefined, - durationMinutes: a.episodeLength ?? undefined, - genres: genres.length > 0 ? genres : undefined, - tags: tags.length > 0 ? tags : undefined, - studios: studios.length > 0 ? studios : undefined, - year: parseYear(a.startDate), - season: undefined, // Kitsu doesn't expose a season enum - startDate: a.startDate ?? undefined, - endDate: a.endDate ?? undefined, - score: - typeof a.averageRating === 'string' ? Math.round(parseFloat(a.averageRating)) : undefined, - trailer: a.youtubeVideoId ? `https://www.youtube.com/watch?v=${a.youtubeVideoId}` : undefined, - isAdult: a.ageRating === 'R18' || a.ageRating === 'R18+' || a.nsfw === true, - synonyms: Array.isArray(a.abbreviatedTitles) - ? a.abbreviatedTitles.filter(Boolean) - : undefined, - // `mappings` first so the primary key wins on conflict — Kitsu's own - // ID is authoritative here. - mappings: { ...mappings, kitsu: Number(rawId) }, - }; - } -} - -function mapToSearchResult( - r: any, - catalog: MediaCatalogType, - path: 'anime' | 'manga', -): IMetaSearchResult { - const a = r.attributes ?? {}; - return { - id: `${path}:${r.id}`, - providerId: 'kitsu', - catalogType: catalog, - title: kitsuTitles(a), - cover: a.posterImage - ? { - large: a.posterImage.large ?? a.posterImage.original ?? undefined, - medium: a.posterImage.medium ?? undefined, - small: a.posterImage.small ?? undefined, - } - : undefined, - year: parseYear(a.startDate), - format: kitsuFormat(a.subtype), - score: - typeof a.averageRating === 'string' ? Math.round(parseFloat(a.averageRating)) : undefined, - isAdult: a.ageRating === 'R18' || a.ageRating === 'R18+' || a.nsfw === true, - mappings: { kitsu: Number(r.id) }, - }; -} - -function kitsuTitles(a: any) { - const t = a?.titles ?? {}; - return { - romaji: t.en_jp ?? a?.canonicalTitle ?? undefined, - english: t.en ?? undefined, - native: t.ja_jp ?? undefined, - userPreferred: a?.canonicalTitle ?? t.en_jp ?? t.en ?? undefined, - }; -} - -function kitsuStatus(s: unknown): MediaStatus | undefined { - if (typeof s !== 'string') return undefined; - switch (s) { - case 'finished': - return 'FINISHED'; - case 'current': - return 'RELEASING'; - case 'upcoming': - case 'tba': - return 'NOT_YET_RELEASED'; - case 'unreleased': - return 'CANCELLED'; - default: - return 'UNKNOWN'; - } -} - -function kitsuFormat(s: unknown): MediaFormat | undefined { - if (typeof s !== 'string') return undefined; - switch (s) { - case 'TV': - return 'TV'; - case 'movie': - return 'MOVIE'; - case 'OVA': - return 'OVA'; - case 'ONA': - return 'ONA'; - case 'special': - return 'SPECIAL'; - case 'music': - return 'MUSIC'; - case 'manga': - return 'MANGA'; - case 'novel': - return 'NOVEL'; - case 'oneshot': - return 'ONE_SHOT'; - default: - return 'UNKNOWN'; - } -} - -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 pickNames( - included: any[], - refs: Array<{ id: string; type: string }> | undefined, - expectedType: string, -): string[] { - if (!Array.isArray(refs)) return []; - const lookup = new Map(included.map((i) => [`${i.type}:${i.id}`, i])); - const out: string[] = []; - for (const ref of refs) { - const node = lookup.get(`${expectedType}:${ref.id}`); - if (node?.attributes?.name) out.push(node.attributes.name); - } - return out; -} - -function pickProducers( - included: any[], - refs: Array<{ id: string; type: string }> | undefined, -): string[] { - if (!Array.isArray(refs)) return []; - const productions = included.filter((i) => i.type === 'animeProductions'); - const producers = new Map( - included.filter((i) => i.type === 'producers').map((i) => [i.id, i.attributes?.name]), - ); - const out: string[] = []; - for (const ref of refs) { - const prod = productions.find((p) => p.id === ref.id); - const producerId = prod?.relationships?.producer?.data?.id; - const name = producerId ? producers.get(producerId) : undefined; - if (name) out.push(name); - } - return out; -} - -/** - * Pull the cross-source `mappings` records (MAL / AniList / etc.) that - * Kitsu's `relationships.mappings` exposes. This is what makes Kitsu - * usable as an alternative entry point — even if the user knows only a - * Kitsu ID, we can route to MALSync via the AniList/MAL crosslink. - */ -function pickMappings( - included: any[], - refs: Array<{ id: string; type: string }> | undefined, -): { anilist?: number; mal?: number; anidb?: number; thetvdb?: number } { - if (!Array.isArray(refs)) return {}; - const out: { anilist?: number; mal?: number; anidb?: number; thetvdb?: number } = {}; - for (const ref of refs) { - const node = included.find((i) => i.type === 'mappings' && i.id === ref.id); - const site = node?.attributes?.externalSite as string | undefined; - const externalId = node?.attributes?.externalId as string | undefined; - if (!site || !externalId) continue; - const num = Number(externalId); - if (!Number.isFinite(num)) continue; - if (site === 'myanimelist/anime' || site === 'myanimelist/manga') out.mal = num; - else if (site === 'anilist/anime' || site === 'anilist/manga') out.anilist = num; - else if (site === 'anidb') out.anidb = num; - else if (site === 'thetvdb') out.thetvdb = num; - } - return out; -} diff --git a/src/meta/MalMeta.ts b/src/meta/MalMeta.ts deleted file mode 100644 index 6b46c2c..0000000 --- a/src/meta/MalMeta.ts +++ /dev/null @@ -1,414 +0,0 @@ -import { HttpClient } from '../transport/http.js'; -import { - CallOptions, - IMediaMetadata, - IMediaRelation, - IMetaSearchResult, - IStreamingEpisode, - MediaCatalogType, - MediaFormat, - MediaRelationType, - MediaSeason, - MediaStatus, -} from '../types/index.js'; -import { buildTypedUrn } from '../utils/urn.js'; -import { - BaseMetadataProvider, - BaseMetadataProviderOptions, - BrowseKind, - BrowseOptions, -} from './BaseMetadataProvider.js'; - -/** - * MyAnimeList metadata provider, backed by the public Jikan API. - * - * Jikan v4 is unofficial but the de-facto MAL gateway used across the - * community. No API key. Rate limit is 3 req/s, 60 req/min — modest, so - * users hammering this in production should plug in a cache. - * - * Jikan splits anime and manga across `/anime` and `/manga` paths; both are - * supported here. Native IDs are MAL IDs (numeric). - */ -export interface MalMetaOptions extends BaseMetadataProviderOptions { - apiUrl?: string; - /** - * Which catalogue to query for the *search* endpoint when both are - * supported. Defaults to `ANIME` — most consumers want anime. Per-record - * fetches always honour the record's actual type. - */ - defaultSearchType?: 'ANIME' | 'MANGA'; -} - -const JIKAN_API = 'https://api.jikan.moe/v4'; - -export class MalMeta extends BaseMetadataProvider { - public readonly id = 'mal'; - public readonly supportedTypes: MediaCatalogType[] = ['ANIME', 'MANGA']; - private apiUrl: string; - private defaultSearchType: 'ANIME' | 'MANGA'; - - constructor(http: HttpClient, options: MalMetaOptions = {}) { - super(http, options); - this.apiUrl = options.apiUrl ?? JIKAN_API; - this.defaultSearchType = options.defaultSearchType ?? 'ANIME'; - } - - protected async searchRawNative( - query: string, - options: CallOptions = {}, - ): Promise { - const path = this.defaultSearchType === '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: options.signal, - }); - if (res.status !== 200) { - throw new Error(`Jikan search failed with status ${res.status}`); - } - const json = (await res.json()) as any; - const data: any[] = json?.data ?? []; - return data.map((m) => ({ - id: `${path}:${m.mal_id}`, - providerId: this.id, - catalogType: path === 'manga' ? 'MANGA' : 'ANIME', - title: malTitles(m), - cover: m.images - ? { - large: m.images.jpg?.large_image_url ?? undefined, - medium: m.images.jpg?.image_url ?? undefined, - small: m.images.jpg?.small_image_url ?? undefined, - } - : undefined, - year: m.year ?? (m.aired?.from ? new Date(m.aired.from).getUTCFullYear() : undefined), - format: malFormat(m.type), - score: typeof m.score === 'number' ? Math.round(m.score * 10) : undefined, - isAdult: !!m.rating?.startsWith?.('Rx'), - mappings: { mal: m.mal_id }, - })); - } - - protected async fetchMediaInfoRawNative( - nativeId: string, - options: CallOptions = {}, - ): Promise { - // `nativeId` arrives as either the typed form `"anime:21"` or the bare - // numeric form `"21"` (legacy). Honour the typed form when present — - // otherwise fall back to the anime-first / manga-second probe. - let path: 'anime' | 'manga' = 'anime'; - let numericId: string; - const sep = nativeId.indexOf(':'); - if (sep >= 0) { - const prefix = nativeId.slice(0, sep); - if (prefix === 'anime' || prefix === 'manga') { - path = prefix; - numericId = nativeId.slice(sep + 1); - } else { - numericId = nativeId; - } - } else { - numericId = nativeId; - } - const id = Number(numericId); - if (!Number.isFinite(id)) { - throw new Error(`Invalid MAL ID: ${nativeId}`); - } - let res = await this.http.get(`${this.apiUrl}/${path}/${id}/full`, { - headers: { Accept: 'application/json' }, - signal: options.signal, - }); - if (res.status === 404 && sep < 0) { - // Untyped legacy IDs only: probe the other catalogue. - path = 'manga'; - res = await this.http.get(`${this.apiUrl}/${path}/${id}/full`, { - headers: { Accept: 'application/json' }, - signal: options.signal, - }); - } - if (res.status !== 200) { - throw new Error(`Jikan fetchMediaInfo failed with status ${res.status}`); - } - const json = (await res.json()) as any; - const m = json?.data; - if (!m) throw new Error(`MAL: no media for id ${id}`); - - const trailerUrl = - m.trailer?.youtube_id != null - ? `https://www.youtube.com/watch?v=${m.trailer.youtube_id}` - : undefined; - - const relations = mapJikanRelations(this.id, m.relations); - const streamingEpisodes = - path === 'anime' ? await this.fetchAnimeEpisodes(id, options.signal) : undefined; - return { - id: `${path}:${m.mal_id}`, - providerId: this.id, - catalogType: path === 'manga' ? 'MANGA' : 'ANIME', - title: malTitles(m), - description: m.synopsis ?? undefined, - cover: m.images - ? { - large: m.images.jpg?.large_image_url ?? undefined, - medium: m.images.jpg?.image_url ?? undefined, - small: m.images.jpg?.small_image_url ?? undefined, - } - : undefined, - banner: undefined, - status: malStatus(m.status, path), - format: malFormat(m.type), - episodeCount: m.episodes ?? undefined, - chapterCount: m.chapters ?? undefined, - durationMinutes: parseDurationMinutes(m.duration), - genres: - Array.isArray(m.genres) && m.genres.length > 0 - ? m.genres.map((g: any) => g.name).filter(Boolean) - : undefined, - tags: - Array.isArray(m.themes) && m.themes.length > 0 - ? m.themes.map((t: any) => t.name).filter(Boolean) - : undefined, - studios: - Array.isArray(m.studios) && m.studios.length > 0 - ? m.studios.map((s: any) => s.name).filter(Boolean) - : undefined, - year: m.year ?? (m.aired?.from ? new Date(m.aired.from).getUTCFullYear() : undefined), - season: malSeason(m.season), - startDate: m.aired?.from?.slice(0, 10) ?? m.published?.from?.slice(0, 10) ?? undefined, - endDate: m.aired?.to?.slice(0, 10) ?? m.published?.to?.slice(0, 10) ?? undefined, - score: typeof m.score === 'number' ? Math.round(m.score * 10) : undefined, - trailer: trailerUrl, - isAdult: !!m.rating?.startsWith?.('Rx'), - synonyms: Array.isArray(m.title_synonyms) ? m.title_synonyms.filter(Boolean) : undefined, - mappings: { mal: m.mal_id }, - relations, - streamingEpisodes, - }; - } - - /** - * Fetch episode-level metadata (filler/recap flags + episode titles) - * from Jikan's `/anime/{id}/episodes` paginated endpoint. We page until - * exhausted (Jikan returns 100 per page). - * - * Best-effort — returns `undefined` if any page fails so the parent - * call doesn't blow up on a flaky upstream. - */ - private async fetchAnimeEpisodes( - malId: number, - signal?: AbortSignal, - ): Promise { - const out: IStreamingEpisode[] = []; - let page = 1; - let hasNext = true; - try { - while (hasNext && page <= 10) { - const res = await this.http.get(`${this.apiUrl}/anime/${malId}/episodes?page=${page}`, { - headers: { Accept: 'application/json' }, - signal, - }); - if (res.status !== 200) break; - const data = (await res.json()) as { - data?: Array<{ - mal_id?: number; - title?: string; - filler?: boolean; - recap?: boolean; - aired?: string; - }>; - pagination?: { has_next_page?: boolean }; - }; - for (const ep of data.data ?? []) { - if (typeof ep.mal_id === 'number') { - out.push({ - number: ep.mal_id, - title: ep.title || undefined, - isFiller: typeof ep.filler === 'boolean' ? ep.filler : undefined, - isRecap: typeof ep.recap === 'boolean' ? ep.recap : undefined, - airDate: ep.aired ? ep.aired.slice(0, 10) : undefined, - }); - } - } - hasNext = !!data.pagination?.has_next_page; - page += 1; - } - } catch { - return out.length > 0 ? out : undefined; - } - return out.length > 0 ? out : undefined; - } - - public override supportsBrowseKind(kind: BrowseKind): boolean { - return kind === 'top' || kind === 'popular' || kind === 'seasonal'; - } - - protected override async browseRawNative( - kind: BrowseKind, - options: BrowseOptions, - ): Promise { - const catalogType = options.catalogType ?? 'ANIME'; - const path = catalogType === 'MANGA' ? 'manga' : 'anime'; - const page = options.page ?? 1; - const perPage = Math.min(options.perPage ?? 20, 25); - - let url: string; - if (kind === 'seasonal') { - if (path !== 'anime') { - throw new Error('Jikan browse(seasonal): only ANIME is supported'); - } - if (!options.season || !options.year) { - throw new Error('Jikan browse(seasonal): season and year are required'); - } - const season = options.season.toLowerCase(); - url = `${this.apiUrl}/seasons/${options.year}/${season}?page=${page}&limit=${perPage}`; - } else if (kind === 'top') { - url = `${this.apiUrl}/top/${path}?page=${page}&limit=${perPage}`; - } else { - // popular — Jikan's `/top/{anime,manga}` sorted by popularity - url = `${this.apiUrl}/top/${path}?filter=bypopularity&page=${page}&limit=${perPage}`; - } - - const res = await this.http.get(url, { - headers: { Accept: 'application/json' }, - signal: options.signal, - }); - if (res.status !== 200) throw new Error(`Jikan browse failed with status ${res.status}`); - const json = (await res.json()) as { data?: any[] }; - const data = json?.data ?? []; - return data.map((m) => ({ - id: `${path}:${m.mal_id}`, - providerId: this.id, - catalogType: path === 'manga' ? 'MANGA' : 'ANIME', - title: malTitles(m), - cover: m.images - ? { - large: m.images.jpg?.large_image_url ?? undefined, - medium: m.images.jpg?.image_url ?? undefined, - small: m.images.jpg?.small_image_url ?? undefined, - } - : undefined, - year: m.year ?? (m.aired?.from ? new Date(m.aired.from).getUTCFullYear() : undefined), - format: malFormat(m.type), - score: typeof m.score === 'number' ? Math.round(m.score * 10) : undefined, - isAdult: !!m.rating?.startsWith?.('Rx'), - mappings: { mal: m.mal_id }, - })); - } -} - -/** - * Map Jikan's `relations` array (in `/anime/{id}/full`) onto - * {@link IMediaRelation}[]. Jikan uses verbose human strings like - * "Sequel"/"Prequel"/"Side story"/"Spin-off"/"Adaptation" which we - * normalize into the SDK's enum. - */ -function mapJikanRelations( - metaProviderId: string, - relations: unknown, -): IMediaRelation[] | undefined { - if (!Array.isArray(relations) || relations.length === 0) return undefined; - const out: IMediaRelation[] = []; - for (const block of relations as Array<{ - relation?: string; - entry?: Array<{ mal_id?: number; type?: string; name?: string; url?: string }>; - }>) { - const rt = normalizeRelation(block.relation); - for (const entry of block.entry ?? []) { - if (!entry.mal_id || !entry.type) continue; - const kind = entry.type.toLowerCase() === 'manga' ? 'manga' : 'anime'; - out.push({ - id: buildTypedUrn(metaProviderId, kind, entry.mal_id), - relationType: rt, - catalogType: kind === 'manga' ? 'MANGA' : 'ANIME', - title: { userPreferred: entry.name ?? undefined, romaji: entry.name ?? undefined }, - }); - } - } - return out.length > 0 ? out : undefined; -} - -function normalizeRelation(s: unknown): MediaRelationType { - if (typeof s !== 'string') return 'OTHER'; - const v = s.toLowerCase(); - if (v.includes('sequel')) return 'SEQUEL'; - if (v.includes('prequel')) return 'PREQUEL'; - if (v.includes('side')) return 'SIDE_STORY'; - if (v.includes('spin')) return 'SPIN_OFF'; - if (v.includes('adaptation')) return 'ADAPTATION'; - if (v.includes('alternative')) return 'ALTERNATIVE'; - if (v.includes('summary')) return 'SUMMARY'; - if (v.includes('character')) return 'CHARACTER'; - if (v.includes('parent')) return 'PARENT'; - if (v.includes('full')) return 'PARENT'; - return 'OTHER'; -} - -function malTitles(m: any) { - 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 { - romaji: titles['default'] ?? m.title ?? undefined, - english: titles['english'] ?? m.title_english ?? undefined, - native: titles['japanese'] ?? m.title_japanese ?? undefined, - userPreferred: titles['english'] ?? m.title_english ?? m.title ?? undefined, - }; -} - -function malFormat(t: unknown): MediaFormat | undefined { - if (typeof t !== 'string') return undefined; - switch (t) { - case 'TV': - return 'TV'; - case 'Movie': - return 'MOVIE'; - case 'OVA': - return 'OVA'; - case 'ONA': - return 'ONA'; - case 'Special': - return 'SPECIAL'; - case 'Music': - return 'MUSIC'; - case 'Manga': - return 'MANGA'; - case 'Novel': - case 'Light Novel': - return 'NOVEL'; - case 'One-shot': - return 'ONE_SHOT'; - default: - return 'UNKNOWN'; - } -} - -function malStatus(s: unknown, _kind: 'anime' | 'manga'): MediaStatus | undefined { - if (typeof s !== 'string') return undefined; - if (s === 'Finished Airing' || s === 'Finished' || s === 'Complete') return 'FINISHED'; - if (s === 'Currently Airing' || s === 'Publishing') return 'RELEASING'; - if (s === 'Not yet aired' || s === 'Not yet published') return 'NOT_YET_RELEASED'; - if (s === 'On Hiatus') return 'HIATUS'; - if (s === 'Discontinued') return 'CANCELLED'; - return 'UNKNOWN'; -} - -function malSeason(s: unknown): MediaSeason | undefined { - if (typeof s !== 'string') return undefined; - const v = s.toUpperCase(); - if (v === 'WINTER' || v === 'SPRING' || v === 'SUMMER' || v === 'FALL') return v; - return undefined; -} - -function parseDurationMinutes(d: unknown): number | undefined { - // Jikan returns strings like "24 min per ep" / "1 hr 32 min" / "Unknown" - if (typeof d !== 'string') return undefined; - const m = d.match(/(\d+)\s*hr/); - const mins = d.match(/(\d+)\s*min/); - let total = 0; - if (m) total += parseInt(m[1], 10) * 60; - if (mins) total += parseInt(mins[1], 10); - return total > 0 ? total : undefined; -} diff --git a/src/meta/MappingClient.ts b/src/meta/MappingClient.ts deleted file mode 100644 index 6eeea8a..0000000 --- a/src/meta/MappingClient.ts +++ /dev/null @@ -1,563 +0,0 @@ -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; -} diff --git a/src/meta/index.ts b/src/meta/index.ts deleted file mode 100644 index ad4f603..0000000 --- a/src/meta/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from './BaseMetadataProvider.js'; -export * from './MappingClient.js'; -export * from './similarity.js'; -export * from './AnilistMeta.js'; -export * from './MalMeta.js'; -export * from './KitsuMeta.js'; diff --git a/src/progressive.ts b/src/progressive.ts new file mode 100644 index 0000000..d86cf29 --- /dev/null +++ b/src/progressive.ts @@ -0,0 +1,91 @@ +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/providers/AllmangaProvider.ts b/src/providers/AllmangaProvider.ts deleted file mode 100644 index b453bc6..0000000 --- a/src/providers/AllmangaProvider.ts +++ /dev/null @@ -1,462 +0,0 @@ -import { BaseProvider, CallOptions } from './BaseProvider.js'; -import { HttpClient } from '../transport/http.js'; -import { aesDecryptCtr } from '../utils/crypto.js'; -import { Mp4UploadExtractor } from '../extractors/Mp4UploadExtractor.js'; -import { GenericHlsExtractor } from '../extractors/GenericHlsExtractor.js'; -import { - IMediaSearchResult, - IContentUnit, - ResolvedMediaStream, - MediaCatalogType, - IVideoPayload, - ContentLanguage, -} from '../types/index.js'; - -export interface AllmangaOptions { - baseUrl?: string; - /** Default language to use if not specified per-call. Defaults to 'sub'. */ - defaultLanguage?: ContentLanguage; -} - -/** - * Decode an AllAnime obfuscated source URL. - * - * AllAnime prefixes obfuscated URLs with `--` followed by a hex string. Each - * pair of hex digits is XOR'd with 0x38 to recover the original byte. After - * decoding, the path `/clock` is rewritten to `/clock.json`. - */ -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; - } - // latin1 preserves the raw byte values for non-ASCII characters - const decoded = new TextDecoder('latin1').decode(bytes); - return decoded.replace(/\/clock(?=\?|$)/, '/clock.json'); -} - -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'; - -export class AllmangaProvider extends BaseProvider { - public readonly id = 'allmanga'; - public readonly supportedTypes: MediaCatalogType[] = ['ANIME']; - private apiBase = 'https://api.allanime.day/api'; - private apiHost = 'https://allanime.day'; - private referer = 'https://allmanga.to'; - private origin = 'https://allmanga.to'; - private defaultLanguage: ContentLanguage; - private mp4UploadExtractor: Mp4UploadExtractor; - private genericExtractor: GenericHlsExtractor; - - constructor(http: HttpClient, options: AllmangaOptions = {}) { - super(http); - if (options.baseUrl) { - this.apiBase = options.baseUrl; - } - this.defaultLanguage = options.defaultLanguage ?? 'sub'; - this.mp4UploadExtractor = new Mp4UploadExtractor(this.http); - this.genericExtractor = new GenericHlsExtractor(this.http); - } - - // ─── Search ──────────────────────────────────────────────────────────────── - - protected async searchRaw( - query: string, - options: CallOptions = {}, - ): Promise { - const searchGql = `query($search: SearchInput, $limit: Int, $page: Int, $countryOrigin: VaildCountryOriginEnumType) { - shows(search: $search, limit: $limit, page: $page, countryOrigin: $countryOrigin) { - edges { _id name englishName availableEpisodes __typename } - } - }`; - - const variables = { - search: { allowAdult: false, allowUnknown: false, query }, - limit: 40, - page: 1, - countryOrigin: 'ALL', - }; - - const res = await this.http.post( - this.apiBase, - { variables, query: searchGql }, - { headers: this.apiHeaders(), signal: options.signal }, - ); - - if (res.status !== 200) { - throw new Error(`AllManga search failed with status ${res.status}`); - } - - const json = (await res.json()) as any; - const edges = json?.data?.shows?.edges ?? []; - const out: IMediaSearchResult[] = []; - - for (const edge of edges) { - const title = edge.englishName || edge.name; - if (!title) continue; - const avail = edge.availableEpisodes as Record | undefined; - const langs: ContentLanguage[] = []; - if (avail?.sub) langs.push('sub'); - if (avail?.dub) langs.push('dub'); - if (avail?.raw) langs.push('raw'); - out.push({ - id: edge._id, - title, - catalogType: 'ANIME', - providerId: this.id, - availableLanguages: langs.length > 0 ? langs : undefined, - }); - } - return out; - } - - // ─── Episodes ────────────────────────────────────────────────────────────── - - protected async fetchContentUnitsRaw( - mediaId: string, - options: CallOptions = {}, - ): 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: options.signal }, - ); - if (res.status !== 200) { - throw new Error(`Failed to fetch AllManga episodes: ${res.status}`); - } - - const json = (await res.json()) as any; - const detail = json?.data?.show?.availableEpisodesDetail ?? {}; - - // Merge sub/dub/raw episode lists into one canonical list keyed by episode - // number; each unit advertises which translations include it. - 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 units: IContentUnit[] = []; - for (const [epStr, { num, langs }] of merged) { - units.push({ - id: `${mediaId}/${epStr}`, - title: `Episode ${epStr}`, - number: num, - availableLanguages: langs, - }); - } - return units.sort((a, b) => a.number - b.number); - } - - // ─── Streams ─────────────────────────────────────────────────────────────── - - protected async resolveStreamRaw( - unitId: string, - language?: ContentLanguage, - options: CallOptions = {}, - ): Promise { - // Accept both the new `${showId}/${episodeString}` shape and the legacy - // `${showId}/${episodeString}/${lang}` shape so older client links keep - // working until they're refreshed. - const [showId, episodeString, legacyLang] = unitId.split('/'); - if (!showId || !episodeString) { - throw new Error(`Invalid AllManga unit ID: ${unitId}`); - } - const lang = language ?? (legacyLang as ContentLanguage | undefined) ?? this.defaultLanguage; - - const sources = await this.fetchEpisodeSources(showId, episodeString, lang, options.signal); - if (sources.length === 0) { - throw new Error(`AllManga returned no source URLs for ${unitId} (lang=${lang})`); - } - - // Process sources in priority order; collect streams across all sources. - sources.sort((a, b) => (Number(b.priority) || 0) - (Number(a.priority) || 0)); - - const streams: IVideoPayload[] = []; - const errors: string[] = []; - - for (const src of sources) { - try { - const extracted = await this.extractSource(src, lang); - streams.push(...extracted); - } catch (e) { - errors.push(`${src.sourceName}: ${(e as Error).message}`); - } - } - - if (streams.length === 0) { - throw new Error( - `AllManga: no playable streams could be extracted for ${unitId}. ` + - `Tried ${sources.length} source(s). Errors: ${errors.join('; ')}`, - ); - } - - // Rank: direct m3u8/mp4 ahead of anything else. - streams.sort((a, b) => qualityScore(b) - qualityScore(a)); - - return { type: 'video', streams }; - } - - // ─── Internals ───────────────────────────────────────────────────────────── - - private apiHeaders(): Record { - return { - 'Content-Type': 'application/json', - Referer: this.referer, - Origin: this.origin, - 'User-Agent': ALLANIME_USER_AGENT, - }; - } - - private async fetchEpisodeSources( - showId: string, - episodeString: string, - lang: ContentLanguage, - 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(`Failed to load AllManga stream sources: ${res.status}`); - } - const json = (await res.json()) as any; - - const tobeparsed: string | undefined = json?.data?.tobeparsed; - if (tobeparsed) { - return this.decryptTobeparsed(tobeparsed); - } - - // Fallback to non-persisted GraphQL request - 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 GraphQL failed with status ${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 blob 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 payload'); - } - return sources; - } - - private async extractSource( - src: { sourceUrl: string; sourceName?: string }, - lang: ContentLanguage, - ): Promise { - let raw = src.sourceUrl; - if (!raw) return []; - - // XOR-decode obfuscated AllAnime API paths (Luf-Mp4, S-mp4, Default, ...) - 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, - }; - - // 1. Internal AllAnime clock.json: returns JSON with `links` array. - if (raw.includes('/clock.json')) { - return this.resolveClockJson(raw, lang); - } - - // 2. Direct media (m3u8 / mp4) - if (/\.m3u8(?:\?|$)/.test(raw) || /\.mp4(?:\?|$)/.test(raw)) { - return [ - { - sourceUrl: raw, - isHLS: raw.includes('.m3u8'), - quality: 'auto', - language: lang, - headers, - }, - ]; - } - - // 3. Yt-mp4 alias: tools.fast4speed.rsvp gives direct mp4 - if (raw.includes('tools.fast4speed.rsvp')) { - return [ - { - sourceUrl: raw, - isHLS: false, - quality: 'auto', - language: lang, - headers, - }, - ]; - } - - // 4. Mp4Upload embed page - extract direct mp4 URL - if (Mp4UploadExtractor.matches(raw)) { - return this.mp4UploadExtractor.extract(raw); - } - - // 5. Best-effort generic HLS/MP4 extraction from embed pages - // (handles vibeplayer.site, otakuvid.online, bibiemb.xyz patterns) - try { - const extracted = await this.genericExtractor.extract(raw); - if (extracted.length > 0) { - return extracted.map((p) => ({ ...p, language: lang })); - } - } catch { - /* fall through */ - } - - // Could not extract; skip this source rather than returning a useless embed URL - return []; - } - - private async resolveClockJson(url: string, lang: ContentLanguage): 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 any, - }); - 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; - - // Wixmp packager → expand quality variants - 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); - } - } -} - -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; - // Provider-priority: mp4upload direct mp4 → wixstatic mp4 → m3u8 → other. - 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.sourceUrl.includes('okcdn.ru') && p.isHLS) s += 5; - 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; -} diff --git a/src/providers/AnikotoProvider.ts b/src/providers/AnikotoProvider.ts deleted file mode 100644 index 63f0b11..0000000 --- a/src/providers/AnikotoProvider.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { BaseProvider, CallOptions } from './BaseProvider.js'; -import { HttpClient } from '../transport/http.js'; -import { DomRegistry } from '../transport/dom.js'; -import { - IMediaSearchResult, - IContentUnit, - ResolvedMediaStream, - MediaCatalogType, - ContentLanguage, - IVideoPayload, - ISubtitleTrack, -} from '../types/index.js'; - -export class AnikotoProvider extends BaseProvider { - public override readonly id = 'anikoto'; - public readonly name = 'Anikoto'; - public override readonly supportedTypes: MediaCatalogType[] = ['ANIME']; - - private readonly baseUrl = 'https://anikototv.to'; - private readonly apiUrl = 'https://anikotoapi.site'; - - constructor(http: HttpClient) { - super(http); - if (!this.http.getDefaultHeaders()['User-Agent']) { - this.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', - ); - } - } - - protected override async searchRaw( - query: string, - options: CallOptions = {}, - ): Promise { - const response = await this.http.get( - `${this.baseUrl}/filter?keyword=${encodeURIComponent(query)}`, - { signal: options.signal }, - ); - const html = await response.text(); - const dom = DomRegistry.parse(html); - - // Target only the main content items to avoid sidebar "top rated" results - const items = dom.querySelectorAll('.main .item'); - - return items - .map((item): IMediaSearchResult => { - const titleEl = item.querySelector('.name'); - const posterEl = item.querySelector('.poster'); - const id = posterEl?.getAttribute('data-tip') || ''; - - return { - id, - title: titleEl?.textContent?.trim() || '', - thumbnailUrl: item.querySelector('img')?.getAttribute('src') || undefined, - catalogType: 'ANIME', - providerId: this.id, - }; - }) - .filter((res) => res.id !== ''); - } - - protected override async fetchContentUnitsRaw( - mediaId: string, - options: CallOptions = {}, - ): Promise { - const response = await this.http.get(`${this.apiUrl}/series/${mediaId}`, { - signal: options.signal, - }); - const json = (await response.json()) as any; - - if (!json.ok || !json.data || !json.data.episodes) { - return []; - } - - const episodes = json.data.episodes; - - return episodes.map((ep: any) => { - const languages: ContentLanguage[] = []; - if (ep.embed_url.sub) languages.push('sub'); - if (ep.embed_url.dub) languages.push('dub'); - - return { - id: ep.episode_embed_id, - title: ep.title || `Episode ${ep.number}`, - number: ep.number, - availableLanguages: languages, - }; - }); - } - - protected override async resolveStreamRaw( - unitId: string, - language: ContentLanguage = 'sub', - options: CallOptions = {}, - ): Promise { - const embedUrl = `https://megaplay.buzz/stream/s-2/${unitId}/${language}`; - - // Step 1: Fetch the embed page to get the file ID - const embedResponse = await this.http.get(embedUrl, { - signal: options.signal, - headers: { - Referer: this.baseUrl, - }, - }); - const embedPage = await embedResponse.text(); - - // The file ID is usually in the title: File 174608 - MegaPlay - const fileIdMatch = embedPage.match(/File\s+(\d+)\s+-/); - if (!fileIdMatch) { - throw new Error('Could not find file ID on megaplay embed page'); - } - const fileId = fileIdMatch[1]; - - // Step 2: Fetch the sources using the file ID - const sourcesResponse = await this.http.get( - `https://megaplay.buzz/stream/getSources?id=${fileId}`, - { - signal: options.signal, - headers: { - Referer: `https://megaplay.buzz/stream/s-5/${unitId}/${language}`, - 'X-Requested-With': 'XMLHttpRequest', - }, - }, - ); - - const sourcesJson = (await sourcesResponse.json()) as any; - if (!sourcesJson.sources || !sourcesJson.sources.file) { - throw new Error('No video sources found in megaplay response'); - } - - const streams: IVideoPayload[] = [ - { - sourceUrl: sourcesJson.sources.file, - isHLS: sourcesJson.sources.file.includes('.m3u8'), - quality: 'auto', - language, - headers: { - Referer: 'https://megaplay.buzz/', - }, - }, - ]; - - const subtitles: ISubtitleTrack[] = (sourcesJson.tracks || []) - .filter((t: any) => t.kind === 'captions') - .map((t: any) => ({ - url: t.file, - label: t.label, - language: t.label.toLowerCase(), - format: t.file.endsWith('.vtt') ? 'vtt' : 'srt', - })); - - if (subtitles.length > 0) { - streams[0].subtitles = subtitles; - } - - return { - type: 'video', - streams, - }; - } -} diff --git a/src/providers/AnimeParadiseProvider.ts b/src/providers/AnimeParadiseProvider.ts deleted file mode 100644 index 3bcd178..0000000 --- a/src/providers/AnimeParadiseProvider.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { BaseProvider, CallOptions } from './BaseProvider.js'; -import { HttpClient } from '../transport/http.js'; -import { - IMediaSearchResult, - IContentUnit, - ResolvedMediaStream, - MediaCatalogType, - IUnitTracks, -} from '../types/index.js'; -import { normalizeSubtitleEntries } from '../utils/subtitles.js'; - -const API_BASE = 'https://api.animeparadise.moe'; -const STREAM_BASE = 'https://stream.animeparadise.moe'; - -export class AnimeParadiseProvider extends BaseProvider { - public readonly id = 'animeparadise'; - public readonly supportedTypes: MediaCatalogType[] = ['ANIME']; - - constructor(http: HttpClient) { - super(http); - } - - protected async searchRaw( - query: string, - options: CallOptions = {}, - ): Promise { - const res = await this.http.get(`${API_BASE}/search?q=${encodeURIComponent(query)}&limit=20`, { - signal: options.signal, - }); - const json = (await res.json()) as any; - const items: any[] = json?.data ?? []; - return items.map((item) => ({ - id: item._id, - title: item.alternativeTitle?.english ?? item.title, - thumbnailUrl: item.posterImage?.medium ?? item.posterImage?.large, - catalogType: 'ANIME' as const, - providerId: this.id, - year: - typeof item.year === 'number' - ? item.year - : item.released - ? new Date(item.released).getUTCFullYear() - : undefined, - })); - } - - protected async fetchContentUnitsRaw( - mediaId: string, - options: CallOptions = {}, - ): Promise { - const res = await this.http.get(`${API_BASE}/anime/${mediaId}/episode`, { - signal: options.signal, - }); - const json = (await res.json()) as any; - const episodes: any[] = json?.data ?? []; - return episodes.map((ep) => ({ - // encode uid and animeId so resolveStream can call /ep/{uid}?origin={animeId} - id: `${ep.uid}:${mediaId}`, - title: ep.title ?? `Episode ${ep.number}`, - number: parseFloat(ep.number), - availableLanguages: ['sub'] as const, - })); - } - - protected async resolveStreamRaw( - unitId: string, - _language?: import('../types/index.js').ContentLanguage, - options: CallOptions = {}, - ): Promise { - const sep = unitId.lastIndexOf(':'); - if (sep < 0) throw new Error(`AnimeParadise: invalid unitId "${unitId}"`); - const uid = unitId.slice(0, sep); - const animeId = unitId.slice(sep + 1); - - const res = await this.http.get(`${API_BASE}/ep/${uid}?origin=${animeId}`, { - signal: options.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 streamUrl = `${STREAM_BASE}/m3u8?url=${encodeURIComponent(episode.streamLink)}`; - const subtitles = normalizeSubtitleEntries(episode.subData); - - return { - type: 'video', - streams: [ - { - sourceUrl: streamUrl, - isHLS: true, - quality: 'auto', - language: 'sub', - headers: { Referer: 'https://animeparadise.moe/' }, - ...(subtitles.length > 0 ? { subtitles } : {}), - }, - ], - }; - } - - /** - * Fetch the subtitle/quality availability for a unit without resolving the - * stream itself. AnimeParadise exposes `subData` on the `/ep/:uid` response, - * so this is one cheap round-trip — useful for populating a subtitle selector - * before the user hits play. - */ - protected async fetchUnitTracksRaw( - unitId: string, - _language?: import('../types/index.js').ContentLanguage, - options: CallOptions = {}, - ): Promise { - const sep = unitId.lastIndexOf(':'); - if (sep < 0) throw new Error(`AnimeParadise: invalid unitId "${unitId}"`); - const uid = unitId.slice(0, sep); - const animeId = unitId.slice(sep + 1); - - const res = await this.http.get(`${API_BASE}/ep/${uid}?origin=${animeId}`, { - signal: options.signal, - }); - const json = (await res.json()) as { data?: { episode?: { subData?: unknown } } }; - const subtitles = normalizeSubtitleEntries(json?.data?.episode?.subData); - // AnimeParadise serves a single auto-ladder HLS manifest per episode — we - // don't know the rendition list without fetching the master, so 'auto' is - // the only signal we can give up-front. - return { - subtitles, - qualities: ['auto'], - headers: { Referer: 'https://animeparadise.moe/' }, - }; - } -} diff --git a/src/providers/BaseProvider.ts b/src/providers/BaseProvider.ts deleted file mode 100644 index a146a60..0000000 --- a/src/providers/BaseProvider.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { HttpClient } from '../transport/http.js'; -import { - CallOptions, - IMediaSearchResult, - IContentUnit, - ResolvedMediaStream, - MediaCatalogType, - ContentLanguage, - IMediaMappings, - IUnitTracks, - Urn, -} from '../types/index.js'; -import { buildUrn, unwrapUrn } from '../utils/urn.js'; - -// Re-export so subclasses can stay close to the type they need. -export type { CallOptions } from '../types/index.js'; - -/** - * @deprecated Use {@link CallOptions} from `src/types/index.ts` — the - * single canonical options bag threaded through every public method. - * Kept as an alias for source-compat while the SDK ages out the old name. - */ -export type ProviderCallOptions = CallOptions; - -/** - * Base contract every content provider implements. - * - * ## Unified IDs - * - * Every `id` flowing in or out of a provider is a URN of shape - * `${providerId}:${rawId}`. The public methods (`search`, - * `fetchContentUnits`, `resolveStream`, `fetchUnitTracks`) handle that - * prefixing transparently — subclasses implement the `*Raw` variants and - * deal exclusively with the raw provider-specific IDs. - * - * Bare (non-URN) IDs are still accepted as input for one release of - * backwards-compatibility, but new code should always pass URNs. - */ -export abstract class BaseProvider { - abstract readonly id: string; - abstract readonly supportedTypes: MediaCatalogType[]; - - constructor(protected http: HttpClient) {} - - /** - * Simple FIFO semaphore. Constructed lazily so subclasses that don't - * cap concurrency pay nothing for it. - */ - private __semaphore?: Semaphore; - private withConcurrency(fn: () => Promise): Promise { - if (!this.maxConcurrency || this.maxConcurrency <= 0) return fn(); - if (!this.__semaphore) this.__semaphore = new Semaphore(this.maxConcurrency); - return this.__semaphore.run(fn); - } - - // ── Public API ──────────────────────────────────────────────────────────── - // These wrap the `Raw` methods below with URN encoding/decoding so callers - // (and the meta layer) only ever see URN-formatted IDs. Every method also - // accepts an optional `signal` for cancellation; subclasses are expected - // to forward it on every outbound `http` call. - - public search(query: string, options: CallOptions = {}): Promise { - return this.withConcurrency(async () => { - const results = await this.searchRaw(query, options); - return results.map((r) => ({ - ...r, - id: buildUrn(this.id, r.id), - providerId: this.id, - })); - }); - } - - public fetchContentUnits(mediaUrn: Urn, options: CallOptions = {}): Promise { - return this.withConcurrency(async () => { - const raw = unwrapUrn(this.id, mediaUrn); - const units = await this.fetchContentUnitsRaw(raw, options); - return units.map((u) => ({ ...u, id: buildUrn(this.id, u.id) })); - }); - } - - public resolveStream( - unitUrn: Urn, - language?: ContentLanguage, - options: CallOptions = {}, - ): Promise { - return this.withConcurrency(async () => { - const raw = unwrapUrn(this.id, unitUrn); - return this.resolveStreamRaw(raw, language, options); - }); - } - - /** True iff the provider implements `fetchUnitTracksRaw`. */ - public get supportsUnitTracks(): boolean { - return typeof this.fetchUnitTracksRaw === 'function'; - } - - public fetchUnitTracks( - unitUrn: Urn, - language?: ContentLanguage, - options: CallOptions = {}, - ): Promise { - if (!this.fetchUnitTracksRaw) { - throw new Error(`${this.id}: fetchUnitTracks is not supported by this provider`); - } - return this.withConcurrency(async () => { - const raw = unwrapUrn(this.id, unitUrn); - return this.fetchUnitTracksRaw!(raw, language, options); - }); - } - - /** - * Optional: provider-native cross-source lookup. When a provider's site - * happens to index titles by a well-known external ID (AniList, MAL, …), - * implement this and `MappingClient` will use it before the MALSync / - * fuzzy fallbacks. Return `null` to defer to fallbacks. - */ - public lookupByMapping?(mappings: IMediaMappings, options?: CallOptions): Promise; - - /** - * Optional: comma-keyed array of MALSync `Sites` names this provider - * corresponds to. When set, `MappingClient` will translate MALSync's - * crowdsourced aliases into this provider's namespace automatically — so - * new providers wire themselves in without touching the mapping client. - */ - public static readonly malsyncSites: readonly string[] = []; - - /** - * Optional: maximum number of in-flight calls allowed on this provider. - * Useful for strict sites where parallel fuzzy searches risk a ban. - * `0` / `undefined` means unbounded. - */ - public readonly maxConcurrency: number = 0; - - // ── Subclass surface ────────────────────────────────────────────────────── - // Subclasses implement these with raw (non-URN) IDs. - - 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; -} - -/** - * Minimal FIFO async semaphore used to cap a provider's in-flight calls. - * - * Kept module-local because it's small, used in one spot, and we don't want - * to depend on a third-party limiter just to honour a per-provider cap. - */ -class Semaphore { - private permits: number; - private waiters: Array<() => void> = []; - constructor(max: number) { - this.permits = max; - } - async run(fn: () => Promise): Promise { - await this.acquire(); - try { - return await fn(); - } finally { - this.release(); - } - } - private acquire(): Promise { - if (this.permits > 0) { - this.permits -= 1; - return Promise.resolve(); - } - return new Promise((resolve) => this.waiters.push(resolve)); - } - private release(): void { - const next = this.waiters.shift(); - if (next) next(); - else this.permits += 1; - } -} diff --git a/src/providers/GogoanimeProvider.ts b/src/providers/GogoanimeProvider.ts deleted file mode 100644 index 7c754fa..0000000 --- a/src/providers/GogoanimeProvider.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { BaseProvider, CallOptions } from './BaseProvider.js'; -import { HttpClient } from '../transport/http.js'; -import { DomRegistry } from '../transport/dom.js'; -import { GenericHlsExtractor } from '../extractors/GenericHlsExtractor.js'; -import { - IMediaSearchResult, - IContentUnit, - ResolvedMediaStream, - MediaCatalogType, - IVideoPayload, -} from '../types/index.js'; - -export interface GogoanimeOptions { - baseUrl?: string; -} - -export class GogoanimeProvider extends BaseProvider { - public readonly id = 'gogoanime'; - public readonly supportedTypes: MediaCatalogType[] = ['ANIME']; - private baseUrl = 'https://anineko.to'; - - constructor(http: HttpClient, options: GogoanimeOptions = {}) { - super(http); - if (options.baseUrl) { - this.baseUrl = options.baseUrl; - } - if (!this.http.getDefaultHeaders()['User-Agent']) { - this.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', - ); - } - } - - /** - * Search for anime on AniNeko. - */ - protected async searchRaw( - query: string, - options: CallOptions = {}, - ): Promise { - const searchUrl = `${this.baseUrl}/browser?keyword=${encodeURIComponent(query)}`; - const response = await this.http.get(searchUrl, { signal: options.signal }); - if (response.status !== 200) { - throw new Error(`GogoAnime search failed with status ${response.status}`); - } - - const html = await response.text(); - const doc = DomRegistry.parse(html); - const results: IMediaSearchResult[] = []; - - const cards = doc.querySelectorAll('article.nv-anime-card'); - for (const card of cards) { - 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(); - - let thumbnailUrl = undefined; - const img = card.querySelector('img'); - if (img) { - const src = img.getAttribute('src') || ''; - if (src) { - thumbnailUrl = src.startsWith('http') - ? src - : `${this.baseUrl}${src.startsWith('/') ? '' : '/'}${src}`; - } - } - - results.push({ - id, - title, - thumbnailUrl, - catalogType: 'ANIME', - providerId: this.id, - }); - } - - return results; - } - - /** - * Fetch all content units (episodes) for a given AniNeko anime ID (e.g., "/watch/slug"). - */ - protected async fetchContentUnitsRaw( - mediaId: string, - options: CallOptions = {}, - ): Promise { - let watchUrlPath = mediaId; - // Normalize path to watch page if it is an episode URL - if (mediaId.includes('/watch/')) { - const parts = mediaId.split('/'); - // If path looks like /watch/slug/ep-1, we strip the ep part to get /watch/slug - if (parts.length > 3) { - watchUrlPath = `/${parts[1]}/${parts[2]}`; - } - } else { - // Normalize to watch path format - const slug = mediaId.startsWith('/') ? mediaId.substring(1) : mediaId; - watchUrlPath = `/watch/${slug}`; - } - - const fullUrl = `${this.baseUrl}${watchUrlPath.startsWith('/') ? '' : '/'}${watchUrlPath}`; - const response = await this.http.get(fullUrl, { signal: options.signal }); - if (response.status !== 200) { - throw new Error(`Failed to fetch AniNeko watch page: ${response.status}`); - } - - const html = await response.text(); - const doc = DomRegistry.parse(html); - const episodeItems = doc.querySelectorAll('article.nv-info-episode-item'); - - const units: IContentUnit[] = []; - for (const item of episodeItems) { - const a = item.querySelector('a.nv-info-episode-main'); - if (!a) continue; - - const href = a.getAttribute('href') || ''; - if (!href) continue; - - 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}`; - const id = href.startsWith('/') ? href : `/${href}`; - - units.push({ - id, - title: displayTitle, - number, - availableLanguages: [mediaId.toLowerCase().includes('-dub') ? 'dub' : 'sub'], - }); - } - - return units.sort((a, b) => a.number - b.number); - } - - /** - * Resolve playback stream for a specific content unit (episode) URL path. - */ - protected async resolveStreamRaw( - unitId: string, - _language?: import('../types/index.js').ContentLanguage, - options: CallOptions = {}, - ): Promise { - const fullUrl = `${this.baseUrl}${unitId.startsWith('/') ? '' : '/'}${unitId}`; - const response = await this.http.get(fullUrl, { signal: options.signal }); - if (response.status !== 200) { - throw new Error(`Failed to fetch AniNeko episode page: ${response.status}`); - } - - const html = await response.text(); - const doc = DomRegistry.parse(html); - - // Find all server buttons containing video URLs - const serverButtons = doc.querySelectorAll('button.nv-server-btn'); - const streams: IVideoPayload[] = []; - - for (const btn of serverButtons) { - const videoUrl = btn.getAttribute('data-video'); - if (!videoUrl) continue; - - // Extract server label and tab context - const labelText = (btn.textContent || '').replace(/\s+/g, ' ').trim(); - const tabId = btn.getAttribute('data-tab') || ''; - - // Determine quality or translation status (SUB vs DUB) - let qualityLabel: '1080p' | '720p' | '360p' | 'auto' = 'auto'; - if (labelText.toLowerCase().includes('1080')) qualityLabel = '1080p'; - else if (labelText.toLowerCase().includes('720')) qualityLabel = '720p'; - else if (labelText.toLowerCase().includes('360')) qualityLabel = '360p'; - - let absoluteVideoUrl = videoUrl; - if (videoUrl.startsWith('//')) { - absoluteVideoUrl = 'https:' + videoUrl; - } else if (videoUrl.startsWith('/')) { - absoluteVideoUrl = this.baseUrl.replace(/\/$/, '') + videoUrl; - } - - streams.push({ - sourceUrl: absoluteVideoUrl, - isHLS: absoluteVideoUrl.includes('.m3u8'), - quality: qualityLabel, - headers: { - Referer: fullUrl, - 'User-Agent': this.http.getDefaultHeaders()['User-Agent'] || '', - }, - }); - } - - if (streams.length === 0) { - throw new Error(`No server video streams found on AniNeko episode page: ${unitId}`); - } - - // Resolve embed URLs to direct streams — try sequentially, stop on first success - const extractor = new GenericHlsExtractor(this.http); - let resolved: IVideoPayload[] = []; - for (const embed of streams) { - try { - const extracted = await extractor.extract(embed.sourceUrl); - if (extracted.length > 0) { - resolved = extracted; - break; - } - } catch { - /* try next */ - } - } - - return { - type: 'video', - streams: resolved.length > 0 ? resolved : streams, - }; - } -} diff --git a/src/providers/GoyabuProvider.ts b/src/providers/GoyabuProvider.ts deleted file mode 100644 index 1180181..0000000 --- a/src/providers/GoyabuProvider.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { BaseProvider, CallOptions } from './BaseProvider.js'; -import { HttpClient } from '../transport/http.js'; -import { DomRegistry } from '../transport/dom.js'; -import { BloggerExtractor } from '../extractors/BloggerExtractor.js'; -import { - IMediaSearchResult, - IContentUnit, - ResolvedMediaStream, - MediaCatalogType, - IVideoPayload, -} from '../types/index.js'; - -export interface GoyabuOptions { - baseUrl?: string; -} - -export class GoyabuProvider extends BaseProvider { - public readonly id = 'goyabu'; - public readonly supportedTypes: MediaCatalogType[] = ['ANIME']; - private baseUrl = 'https://goyabu.io'; - private bloggerExtractor: BloggerExtractor; - - constructor(http: HttpClient, options: GoyabuOptions = {}) { - super(http); - if (options.baseUrl) { - this.baseUrl = options.baseUrl; - } - if (!this.http.getDefaultHeaders()['User-Agent']) { - this.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); - } - - /** - * Search for anime on Goyabu. - * Leverages the HTML search fallback method. - */ - protected async searchRaw( - query: string, - options: CallOptions = {}, - ): Promise { - // Replace spaces, hyphens and underscores with plus for search query formatting - const normalized = query.trim().replace(/[-_]/g, ' '); - const searchUrl = `${this.baseUrl}/?s=${encodeURIComponent(normalized)}`; - - const response = await this.http.get(searchUrl, { signal: options.signal }); - if (response.status !== 200) { - throw new Error(`Goyabu search failed with status ${response.status}`); - } - - const html = await response.text(); - const doc = DomRegistry.parse(html); - const results: IMediaSearchResult[] = []; - - // Select article search cards - 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; - - let thumbnailUrl = undefined; - if (img) { - const src = img.getAttribute('src') || img.getAttribute('data-src') || ''; - if (src) { - thumbnailUrl = src.startsWith('http') - ? src - : `${this.baseUrl}${src.startsWith('/') ? '' : '/'}${src}`; - } - } - - results.push({ - id, - title, - thumbnailUrl, - catalogType: 'ANIME', - providerId: this.id, - }); - } - - return results; - } - - /** - * Fetch all content units (episodes) for a given Goyabu anime URL slug (e.g. "/anime/..."). - */ - protected async fetchContentUnitsRaw( - mediaId: string, - options: CallOptions = {}, - ): Promise { - const fullUrl = `${this.baseUrl}${mediaId.startsWith('/') ? '' : '/'}${mediaId}`; - const response = await this.http.get(fullUrl, { signal: options.signal }); - if (response.status !== 200) { - throw new Error(`Failed to fetch Goyabu details page: ${response.status}`); - } - - const html = await response.text(); - const units: IContentUnit[] = []; - - // Regex patterns matching JavaScript array of episodes - 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, - /episodeList\s*[:=]\s*(\[[\s\S]*?\])/i, - /episodios\s*[:=]\s*(\[[\s\S]*?\])/i, - ]; - - let foundArray = false; - for (const pattern of patterns) { - const match = html.match(pattern); - if (!match) continue; - - try { - const jsonStr = match[1]; - // Clean possible unquoted keys ({id:1} -> {"id":1}) or single quotes - let cleaned = jsonStr.replace(/([,{\[\s]|^)(\w+)\s*:/g, '$1"$2":'); - cleaned = cleaned.replace(/'/g, '"'); - - // Remove trailing commas before closing braces if any (JSON strict parsing) - cleaned = cleaned.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; - // Goyabu's episode array exposes a `link` field that's a relative - // path (e.g. "/40742"); use it directly when present. - const link = ep.link || (ep.id ? `/${ep.id}` : ep.ID ? `/${ep.ID}` : ''); - if (!link) continue; - - units.push({ - id: link, - title: ep.episode_name ? `Episódio ${num}: ${ep.episode_name}` : `Episódio ${num}`, - number: num, - availableLanguages: [mediaId.toLowerCase().includes('dublado') ? 'dub' : 'sub'], - }); - } - foundArray = true; - break; - } - } catch (err) { - // Fallback to next match - } - } - - // Fallback: parse static anchor tags from the details HTML page - if (!foundArray || units.length === 0) { - const doc = DomRegistry.parse(html); - const anchors = doc.querySelectorAll('a'); - for (const a of anchors) { - 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 epNumAttr = a.getAttribute('data-episode-number'); - const num = epNumAttr ? parseFloat(epNumAttr) : units.length + 1; - - const id = href.startsWith('http') ? new URL(href).pathname + new URL(href).search : href; - - units.push({ - id, - title: `Episódio ${num}`, - number: num, - availableLanguages: [mediaId.toLowerCase().includes('dublado') ? 'dub' : 'sub'], - }); - } - } - - return units.sort((a, b) => a.number - b.number); - } - - /** - * Resolve playback stream for a specific Goyabu content unit path. - * - * Goyabu wraps all streams in a Blogger video embed (`playersData[].url` - * points at `blogger.com/video.g?token=...`). We use BloggerExtractor to - * call Google's batchexecute API and get back the actual googlevideo.com - * URLs. - */ - protected async resolveStreamRaw( - unitId: string, - _language?: import('../types/index.js').ContentLanguage, - options: CallOptions = {}, - ): Promise { - const fullUrl = `${this.baseUrl}${unitId.startsWith('/') ? '' : '/'}${unitId}`; - const response = await this.http.get(fullUrl, { signal: options.signal }); - if (response.status !== 200) { - throw new Error(`Failed to fetch Goyabu episode page: ${response.status}`); - } - - const html = await response.text(); - const videoSources: IVideoPayload[] = []; - - // Pull Blogger URLs from `playersData = [...]` - const bloggerUrls = this.collectBloggerUrls(html); - const errors: string[] = []; - for (const url of bloggerUrls) { - try { - const extracted = await this.bloggerExtractor.extract(url); - if (extracted.length === 0) { - errors.push(`${url.slice(0, 80)}: Extractor returned 0 results without error`); - } else { - videoSources.push(...extracted); - } - } catch (e) { - errors.push(`${url.slice(0, 80)}: ${(e as Error).message}`); - } - } - - // Fallback: also accept any direct mp4/m3u8 sitting in the page itself. - if (videoSources.length === 0) { - const direct = this.scrapeDirectStreams(html, fullUrl); - videoSources.push(...direct); - } - - if (videoSources.length === 0) { - throw new Error( - `Goyabu: no playable streams for ${unitId}. ` + - (bloggerUrls.length > 0 - ? `Tried ${bloggerUrls.length} Blogger URL(s). Errors: ${errors.join('; ')}` - : 'No Blogger URLs found on the episode page.'), - ); - } - - return { type: 'video', streams: videoSources }; - } - - private collectBloggerUrls(html: string): string[] { - const urls = new Set(); - - // The HTML embeds playersData as JS literal containing JSON-with-escaped-slashes: - // playersData = [{"name":"Blog","url":"https:\/\/www.blogger.com\/video.g?token=..."}] - const playersMatch = html.match(/playersData\s*=\s*(\[[\s\S]*?\])\s*;/i); - if (playersMatch) { - try { - const cleaned = playersMatch[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 { - /* fall through to regex */ - } - } - - // Fallback: raw scan - const re = /https?:(?:\\\/|\/)\/www\.blogger\.com\/video\.g\?token=[A-Za-z0-9_-]+/g; - let m: RegExpExecArray | null; - while ((m = re.exec(html)) !== null) { - urls.add(m[0].replace(/\\\//g, '/')); - } - return Array.from(urls); - } - - private scrapeDirectStreams(html: string, refererUrl: string): IVideoPayload[] { - const out: IVideoPayload[] = []; - const seen = new Set(); - const mapQuality = (label: string): IVideoPayload['quality'] => { - const s = 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'; - }; - - 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'], - [/src\s*[:=]\s*["'](https?:\/\/[^"']+?\.mp4[^"']*)["']/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: mapQuality(q), - headers: { Referer: refererUrl }, - }); - } - } - return out; - } -} diff --git a/src/providers/MangadexProvider.ts b/src/providers/MangadexProvider.ts deleted file mode 100644 index 92ee66b..0000000 --- a/src/providers/MangadexProvider.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { HttpClient } from '../transport/http.js'; -import { - IMediaSearchResult, - IContentUnit, - ResolvedMediaStream, - MediaCatalogType, - ContentLanguage, -} from '../types/index.js'; -import { BaseProvider, CallOptions } from './BaseProvider.js'; - -export class MangadexProvider extends BaseProvider { - readonly id = 'mangadex'; - readonly supportedTypes: MediaCatalogType[] = ['MANGA']; - public static override readonly malsyncSites = ['MangaDex', 'Mangadex'] as const; - - private readonly apiUrl = 'https://api.mangadex.org'; - private readonly coverUrlBase = 'https://uploads.mangadex.org/covers'; - - constructor(http: HttpClient) { - super(http); - } - - protected async searchRaw( - query: string, - options: CallOptions = {}, - ): Promise { - const url = `${this.apiUrl}/manga?title=${encodeURIComponent( - query, - )}&includes[]=cover_art&limit=24&contentRating[]=safe&contentRating[]=suggestive&hasAvailableChapters=true`; - - const res = await this.http.get(url, { signal: options.signal }); - const data = (await res.json()) as any; - - const results: IMediaSearchResult[] = []; - - for (const manga of data.data) { - 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; - const thumbnailUrl = coverFileName - ? `${this.coverUrlBase}/${manga.id}/${coverFileName}.256.jpg` - : undefined; - const yearRaw = manga.attributes.year as number | null | undefined; - - results.push({ - id: manga.id, - title, - thumbnailUrl, - catalogType: 'MANGA', - providerId: this.id, - availableLanguages: ['sub'], - year: typeof yearRaw === 'number' ? yearRaw : undefined, - }); - } - - return results; - } - - protected async fetchContentUnitsRaw( - mediaId: string, - options: CallOptions = {}, - ): Promise { - const units: IContentUnit[] = []; - let offset = 0; - const limit = 500; - let total = 0; - - do { - const url = `${this.apiUrl}/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: options.signal }); - const data = (await res.json()) as any; - - total = data.total; - - for (const chapter of data.data) { - const num = parseFloat(chapter.attributes.chapter); - const lang = chapter.attributes.translatedLanguage; - - units.push({ - id: chapter.id, - title: chapter.attributes.title - ? `Ch. ${chapter.attributes.chapter} - ${chapter.attributes.title}` - : `Chapter ${chapter.attributes.chapter}`, - number: isNaN(num) ? 0 : num, - availableLanguages: [lang === 'en' ? 'sub' : lang], // Map 'en' to 'sub', others as-is or default - }); - } - - offset += limit; - } while (offset < total); - - return units; - } - - protected async resolveStreamRaw( - unitId: string, - language?: ContentLanguage, - options: CallOptions = {}, - ): Promise { - const url = `${this.apiUrl}/at-home/server/${unitId}`; - const res = await this.http.get(url, { signal: options.signal }); - const data = (await res.json()) as any; - - const baseUrl = data.baseUrl; - const hash = data.chapter.hash; - const imageUrls = data.chapter.data.map((file: string) => `${baseUrl}/data/${hash}/${file}`); - - return { - type: 'manga', - pages: { - imageUrls, - headers: { - Referer: 'https://mangadex.org/', - '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', - }, - }, - }; - } -} diff --git a/src/providers/MangapillProvider.ts b/src/providers/MangapillProvider.ts deleted file mode 100644 index b202a93..0000000 --- a/src/providers/MangapillProvider.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { HttpClient } from '../transport/http.js'; -import { DomRegistry } from '../transport/dom.js'; -import { - IMediaSearchResult, - IContentUnit, - ResolvedMediaStream, - MediaCatalogType, - ContentLanguage, -} from '../types/index.js'; -import { BaseProvider, CallOptions } from './BaseProvider.js'; - -export class MangapillProvider extends BaseProvider { - readonly id = 'mangapill'; - readonly supportedTypes: MediaCatalogType[] = ['MANGA']; - public static override readonly malsyncSites = ['Mangapill'] as const; - - private readonly baseUrl = 'https://mangapill.com'; - - constructor(http: HttpClient) { - super(http); - } - - protected async searchRaw( - query: string, - options: CallOptions = {}, - ): Promise { - const url = `${this.baseUrl}/search?q=${encodeURIComponent(query)}`; - - const res = await this.http.get(url, { - signal: options.signal, - headers: { - Referer: this.baseUrl, - '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', - Accept: - 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', - Connection: 'keep-alive', - }, - }); - const html = await res.text(); - const doc = DomRegistry.parse(`
${html}
`); - - const results: IMediaSearchResult[] = []; - - const items = doc.querySelectorAll('div.grid > div'); - for (const item of items) { - const a = item.querySelector('a.mb-2'); - const img = item.querySelector('img'); - const titleEl = a?.querySelector('div'); - - if (!a || !titleEl) continue; - - const href = a.getAttribute('href'); - const title = titleEl.textContent?.trim(); - const coverUrl = img?.getAttribute('data-src') || img?.getAttribute('src'); - - if (href && title) { - results.push({ - id: href.startsWith('/') ? href.slice(1) : href, - title: title, - thumbnailUrl: coverUrl || undefined, - catalogType: 'MANGA', - providerId: this.id, - availableLanguages: ['sub'], - }); - } - } - - return results; - } - - protected async fetchContentUnitsRaw( - mediaId: string, - options: CallOptions = {}, - ): Promise { - const url = `${this.baseUrl}/${mediaId}`; - const res = await this.http.get(url, { - signal: options.signal, - headers: { - Referer: this.baseUrl, - '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', - }, - }); - const html = await res.text(); - const doc = DomRegistry.parse(`
${html}
`); - - const units: IContentUnit[] = []; - const items = doc.querySelectorAll('a.border'); - - for (const item of items) { - const href = item.getAttribute('href'); - if (!href || !href.includes('/chapters/')) continue; - - const title = item.textContent?.trim() || ''; - - let chapterNumber = 0; - const match = title.match(/Chapter\s+(\d+(\.\d+)?)/i); - if (match) { - chapterNumber = parseFloat(match[1]); - } - - units.push({ - id: href.startsWith('/') ? href.slice(1) : href, - title: title, - number: chapterNumber, - availableLanguages: ['sub'], - }); - } - - return units.reverse(); - } - - protected async resolveStreamRaw( - unitId: string, - language?: ContentLanguage, - options: CallOptions = {}, - ): Promise { - const url = `${this.baseUrl}/${unitId}`; - const res = await this.http.get(url, { - signal: options.signal, - headers: { - Referer: this.baseUrl, - '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', - }, - }); - const html = await res.text(); - const doc = DomRegistry.parse(`
${html}
`); - - const imageUrls: string[] = []; - const images = doc.querySelectorAll('.js-page'); - - for (const img of images) { - const src = img.getAttribute('data-src') || img.getAttribute('src'); - if (src) { - imageUrls.push(src); - } - } - - return { - type: 'manga', - pages: { - imageUrls, - headers: { - Referer: this.baseUrl, - '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', - Accept: 'image/avif,image/webp,image/apng,*/*;q=0.8', - Connection: 'keep-alive', - Host: 'mangapill.com', - }, - }, - }; - } -} diff --git a/src/providers/MegaPlayProvider.ts b/src/providers/MegaPlayProvider.ts deleted file mode 100644 index d156303..0000000 --- a/src/providers/MegaPlayProvider.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { BaseProvider, CallOptions } from './BaseProvider.js'; -import { HttpClient } from '../transport/http.js'; -import { DomRegistry } from '../transport/dom.js'; -import { - IMediaSearchResult, - IContentUnit, - IMediaMappings, - ResolvedMediaStream, - MediaCatalogType, - ContentLanguage, - IVideoPayload, - ISubtitleTrack, -} from '../types/index.js'; - -export interface MegaPlayOptions { - baseUrl?: string; -} - -export class MegaPlayProvider extends BaseProvider { - public override readonly id = 'megaplay'; - public readonly name = 'MegaPlay'; - public override readonly supportedTypes: MediaCatalogType[] = ['ANIME']; - - /** - * MegaPlay indexes its catalogue by AniList ID directly — its internal - * `mediaId` IS the AniList ID. Surface that to `MappingClient` so it - * can skip MALSync/Anify/fuzzy entirely when the meta record knows the - * AniList ID. - */ - public override async lookupByMapping(mappings: IMediaMappings): Promise { - return mappings.anilist != null ? String(mappings.anilist) : null; - } - - private readonly baseUrl: string; - private readonly anilistApi = 'https://graphql.anilist.co'; - - constructor(http: HttpClient, options: MegaPlayOptions = {}) { - super(http); - this.baseUrl = options.baseUrl || 'https://megaplay.buzz'; - if (!this.http.getDefaultHeaders()['User-Agent']) { - this.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', - ); - } - } - - protected override async searchRaw( - query: string, - options: CallOptions = {}, - ): Promise { - const graphqlQuery = ` - query ($search: String) { - Page (page: 1, perPage: 15) { - media (search: $search, type: ANIME) { - id - title { - romaji - english - } - coverImage { - large - } - episodes - } - } - } - `; - - const response = await this.http.post( - this.anilistApi, - { - query: graphqlQuery, - variables: { search: query }, - }, - { signal: options.signal }, - ); - - const json = (await response.json()) as any; - if (!json.data || !json.data.Page || !json.data.Page.media) { - return []; - } - - return json.data.Page.media.map( - (media: any): IMediaSearchResult => ({ - id: String(media.id), - title: media.title.english || media.title.romaji, - thumbnailUrl: media.coverImage.large, - catalogType: 'ANIME', - providerId: this.id, - }), - ); - } - - protected override async fetchContentUnitsRaw( - mediaId: string, - options: CallOptions = {}, - ): Promise { - // Fetch episode count from AniList if not provided - const graphqlQuery = ` - query ($id: Int) { - Media (id: $id) { - episodes - } - } - `; - - const response = await this.http.post( - this.anilistApi, - { - query: graphqlQuery, - variables: { id: parseInt(mediaId) }, - }, - { signal: options.signal }, - ); - - const json = (await response.json()) as any; - const episodesCount = json.data?.Media?.episodes || 1; // Default to 1 if unknown - - const units: IContentUnit[] = []; - for (let i = 1; i <= episodesCount; i++) { - units.push({ - id: `${mediaId}:${i}`, - title: `Episode ${i}`, - number: i, - availableLanguages: ['sub', 'dub'], - }); - } - - return units; - } - - protected override async resolveStreamRaw( - unitId: string, - language: ContentLanguage = 'sub', - options: CallOptions = {}, - ): Promise { - const [aniId, epNum] = unitId.split(':'); - const embedUrl = `${this.baseUrl}/stream/ani/${aniId}/${epNum}/${language}`; - - // Step 1: Fetch the embed page to get the file ID - // Note: referer is important for some endpoints - const embedResponse = await this.http.get(embedUrl, { - signal: options.signal, - headers: { - Referer: this.baseUrl, - }, - }); - const embedPage = await embedResponse.text(); - - if (embedPage.includes('Error - MegaPlay')) { - throw new Error( - `MegaPlay has no mapping for AniList ID ${aniId} episode ${epNum} (${language})`, - ); - } - - // The file ID is in the title: File 174608 - MegaPlay - const fileIdMatch = embedPage.match(/File\s+(\d+)\s+-/); - if (!fileIdMatch) { - throw new Error('Could not find file ID on megaplay embed page'); - } - const fileId = fileIdMatch[1]; - - // Step 2: Fetch the sources using the file ID - const sourcesResponse = await this.http.get(`${this.baseUrl}/stream/getSources?id=${fileId}`, { - signal: options.signal, - headers: { - Referer: `${this.baseUrl}/stream/ani/${aniId}/${epNum}/${language}`, - 'X-Requested-With': 'XMLHttpRequest', - }, - }); - - const sourcesJson = (await sourcesResponse.json()) as any; - if (!sourcesJson.sources || !sourcesJson.sources.file) { - throw new Error('No video sources found in megaplay response'); - } - - const streams: IVideoPayload[] = [ - { - sourceUrl: sourcesJson.sources.file, - isHLS: sourcesJson.sources.file.includes('.m3u8'), - quality: 'auto', - language, - headers: { - Referer: `${this.baseUrl}/`, - }, - }, - ]; - - const subtitles: ISubtitleTrack[] = (sourcesJson.tracks || []) - .filter((t: any) => t.kind === 'captions') - .map((t: any) => ({ - url: t.file, - label: t.label, - language: t.label.toLowerCase(), - format: t.file.endsWith('.vtt') ? 'vtt' : 'srt', - })); - - if (subtitles.length > 0) { - streams[0].subtitles = subtitles; - } - - return { - type: 'video', - streams, - }; - } -} diff --git a/src/providers/WeebcentralProvider.ts b/src/providers/WeebcentralProvider.ts deleted file mode 100644 index f817f2a..0000000 --- a/src/providers/WeebcentralProvider.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { HttpClient } from '../transport/http.js'; -import { DomRegistry } from '../transport/dom.js'; -import { - IMediaSearchResult, - IContentUnit, - ResolvedMediaStream, - MediaCatalogType, - ContentLanguage, -} from '../types/index.js'; -import { BaseProvider, CallOptions } from './BaseProvider.js'; - -export class WeebcentralProvider extends BaseProvider { - readonly id = 'weebcentral'; - readonly supportedTypes: MediaCatalogType[] = ['MANGA']; - public static override readonly malsyncSites = ['Weebcentral', 'WeebCentral'] as const; - - private readonly baseUrl = 'https://weebcentral.com/'; - - constructor(http: HttpClient) { - super(http); - } - - protected async searchRaw( - query: string, - options: CallOptions = {}, - ): Promise { - const url = `${this.baseUrl}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: options.signal, - headers: { - Referer: 'https://google.com', - Accept: - 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', - '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', - }, - }); - const html = await res.text(); - const doc = DomRegistry.parse(`
${html}
`); - - const results: IMediaSearchResult[] = []; - - const items = doc.querySelectorAll('article.bg-base-300'); - for (const item of items) { - const a = item.querySelector('a.line-clamp-1'); - const source = item.querySelector('source'); - - if (!a) continue; - - const href = a.getAttribute('href'); - const title = a.textContent?.trim(); - const coverUrl = source?.getAttribute('srcset') || undefined; - - if (href && title) { - const idMatch = href.match(/series\/([A-Z0-9]+)/i); - if (idMatch) { - results.push({ - id: idMatch[1], - title: title, - thumbnailUrl: coverUrl, - catalogType: 'MANGA', - providerId: this.id, - availableLanguages: ['sub'], - }); - } - } - } - - return results; - } - - protected async fetchContentUnitsRaw( - mediaId: string, - options: CallOptions = {}, - ): Promise { - const url = `${this.baseUrl}series/${mediaId}/full-chapter-list`; - const res = await this.http.get(url, { - signal: options.signal, - 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', - }, - }); - const html = await res.text(); - const doc = DomRegistry.parse(`
${html}
`); - - const units: IContentUnit[] = []; - const items = doc.querySelectorAll('div > a'); - - for (const item of items) { - 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 chapterNumber = 0; - const match = title.match(/Chapter\s+(\d+(\.\d+)?)/i); - if (match) { - chapterNumber = parseFloat(match[1]); - } else { - const numMatch = title.match(/(\d+(\.\d+)?)/); - if (numMatch) { - chapterNumber = parseFloat(numMatch[1]); - } - } - - const idMatch = href.match(/chapters\/([A-Z0-9]+)/i); - - if (idMatch) { - units.push({ - id: idMatch[1], - title: title, - number: chapterNumber, - availableLanguages: ['sub'], - }); - } - } - - return units.reverse(); - } - - protected async resolveStreamRaw( - unitId: string, - language?: ContentLanguage, - options: CallOptions = {}, - ): Promise { - const url = `${this.baseUrl}chapters/${unitId}/images?is_prev=False¤t_page=1&reading_style=long_strip`; - const res = await this.http.get(url, { - signal: options.signal, - 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', - }, - }); - const html = await res.text(); - const doc = DomRegistry.parse(`
${html}
`); - - const imageUrls: string[] = []; - const images = doc.querySelectorAll('img'); - - for (const img of images) { - const src = img.getAttribute('src'); - if (src) { - imageUrls.push(src); - } - } - - return { - type: 'manga', - pages: { - imageUrls, - headers: { - Referer: this.baseUrl, - Accept: 'image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5', - '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', - }, - }, - }; - } -} diff --git a/src/registry.ts b/src/registry.ts new file mode 100644 index 0000000..641e250 --- /dev/null +++ b/src/registry.ts @@ -0,0 +1,310 @@ +import type { Source, SourceCallOpts } from './sources/base.js'; +import type { Media, Episode, Chapter, Stream, List, SourceInfo } from './types.js'; +import { HealthTracker } from './health.js'; +import { createProgressiveResult, type ProgressiveResult } from './progressive.js'; +import { bestSimilarity } from './internal/similarity.js'; +import { decodeId } from './internal/id.js'; + +const TITLE_MATCH_THRESHOLD = 0.7; + +export class Registry { + private sources: Source[] = []; + private health = new HealthTracker(); + private mediaCache = new Map(); + private mappingCache = new Map>(); + + register(...sources: Source[]): void { + this.sources.push(...sources); + } + + cacheMedia(media: Media): void { + this.mediaCache.set(media.id, media); + } + + sourcesFor(kind: 'anime' | 'manga', cap: keyof Source['caps']): Source[] { + return this.sources.filter( + (s) => s.kinds.includes(kind) && (s.caps as Record)[cap], + ); + } + + fanOutSearch( + query: string, + kind: 'anime' | 'manga', + opts: SourceCallOpts, + ): ProgressiveResult { + const sources = this.sourcesFor(kind, 'search'); + 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( + media: Media, + opts: SourceCallOpts & { cursor?: string; limit?: number }, + ): Promise> { + this.cacheMedia(media); + 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 = await this.resolveMediaId(media, src, opts); + 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 mergeChapters( + media: Media, + opts: SourceCallOpts & { cursor?: string; limit?: number }, + ): Promise> { + this.cacheMedia(media); + 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: [] }; + } + + streamFromSource(episodeId: string, opts: SourceCallOpts): ProgressiveResult { + const decoded = decodeId(episodeId); + const allStreamSources = this.sources.filter((s) => s.caps.stream); + const src = allStreamSources.find((s) => s.id === decoded.s); + if (!src?.stream) { + return createProgressiveResult( + [ + async () => { + throw new Error(`No stream-capable source for id: ${episodeId}`); + }, + ], + opts.signal, + ); + } + return createProgressiveResult( + [ + async (push, signal) => { + const t0 = Date.now(); + try { + const streams = await src.stream!(episodeId, { signal }); + this.health.record(src.id, true, Date.now() - t0); + for (const s of streams) push(s); + } catch (e) { + this.health.record(src.id, false, Date.now() - t0); + throw e; + } + }, + ], + opts.signal, + ); + } + + streamEpisode(episode: Episode, opts: SourceCallOpts): ProgressiveResult { + const decoded = decodeId(episode.id); + const allStreamSources = this.sources.filter((s) => s.caps.stream); + const primarySrc = allStreamSources.find((s) => s.id === decoded.s); + const otherSrcs = allStreamSources.filter((s) => s.id !== decoded.s); + + const mediaId = decoded.m?.mediaId as string | undefined; + const media = mediaId ? this.mediaCache.get(mediaId) : this.findCachedMediaForEpisode(episode); + + const producers = []; + + if (primarySrc?.stream) { + producers.push(async (push: (item: Stream) => void, signal: AbortSignal) => { + const t0 = Date.now(); + try { + const streams = await primarySrc.stream!(episode.id, { signal }); + this.health.record(primarySrc.id, true, Date.now() - t0); + for (const s of streams) push(s); + } catch (e) { + this.health.record(primarySrc.id, false, Date.now() - t0); + throw e; + } + }); + } + + if (media) { + for (const src of otherSrcs) { + producers.push(async (push: (item: Stream) => void, signal: AbortSignal) => { + const resolvedId = await this.resolveMediaId(media, src, { signal }).catch(() => null); + if (!resolvedId) return; + const epList = await src.episodes!(resolvedId, { signal }).catch(() => null); + if (!epList) return; + const ep = epList.items.find((e) => e.number === episode.number); + if (!ep) return; + const t0 = Date.now(); + try { + const streams = await src.stream!(ep.id, { signal }); + this.health.record(src.id, true, Date.now() - t0); + for (const s of streams) push(s); + } catch (e) { + this.health.record(src.id, false, Date.now() - t0); + } + }); + } + } + + return createProgressiveResult(producers, opts.signal); + } + + async rankPlaybackSources(media: Media, opts: SourceCallOpts): Promise { + const kind = media.kind; + const sources = this.sourcesFor(kind, 'episodes').concat(this.sourcesFor(kind, 'chapters')); + return Promise.all( + sources.map(async (src): Promise => { + const h = this.health.get(src.id); + const mediaId = await this.resolveMediaId(media, src, opts).catch(() => null); + return { + id: src.id, + status: mediaId ? 'available' : 'incompatible', + successRate: h.successRate, + }; + }), + ); + } + + getHealthTracker(): HealthTracker { + return this.health; + } + + async resolveMediaId(media: Media, src: Source, opts: SourceCallOpts): Promise { + const perSource = this.mappingCache.get(media.id); + const cached = perSource?.get(src.id); + if (cached) return cached; + + const cacheAndReturn = (resolved: string) => { + let map = this.mappingCache.get(media.id); + if (!map) { + map = new Map(); + this.mappingCache.set(media.id, map); + } + map.set(src.id, resolved); + return resolved; + }; + + if (src.caps.mapping && src.lookupByMapping) { + try { + const resolved = await src.lookupByMapping(media.mappings as Record, { + signal: opts.signal, + }); + if (resolved) return cacheAndReturn(resolved); + } catch { + // fall through + } + } + + if (src.caps.search && src.search) { + const titles = collectTitles(media); + for (const title of titles) { + try { + const candidates = await src.search(title, media.kind, { signal: opts.signal }); + const best = pickBestMatch(candidates, titles, media.year); + if (best) { + try { + const raw = decodeId(best.id).r; + return cacheAndReturn(raw); + } catch { + // best.id wasn't an opaque token + } + } + } catch { + // search failed; try the next title candidate + } + } + } + + return null; + } + + private findCachedMediaForEpisode(episode: Episode): Media | null { + for (const media of this.mediaCache.values()) { + const decoded = decodeId(episode.id); + if (decoded.s) { + const perSource = this.mappingCache.get(media.id); + if (perSource?.has(decoded.s)) return media; + } + } + return null; + } + + 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; + }); + } +} + +function collectTitles(media: Media): string[] { + const t = media.title; + const seen = new Set(); + const out: string[] = []; + for (const cand of [t.english, t.romaji, t.preferred, t.native]) { + if (!cand) continue; + const k = cand.toLowerCase(); + if (seen.has(k)) continue; + seen.add(k); + out.push(cand); + } + return out; +} + +function pickBestMatch( + candidates: Media[], + referenceTitles: string[], + referenceYear: number | undefined, +): Media | null { + let best: Media | null = null; + let bestScore = 0; + for (const c of candidates) { + if (referenceYear != null && c.year != null && Math.abs(c.year - referenceYear) > 1) { + continue; + } + const candTitles = collectTitles(c); + if (candTitles.length === 0) continue; + let score = 0; + for (const ref of referenceTitles) { + const s = bestSimilarity(ref, candTitles); + if (s > score) score = s; + } + if (score > bestScore && score >= TITLE_MATCH_THRESHOLD) { + bestScore = score; + best = c; + } + } + return best; +} diff --git a/src/sdk.ts b/src/sdk.ts new file mode 100644 index 0000000..c98ecfc --- /dev/null +++ b/src/sdk.ts @@ -0,0 +1,182 @@ +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'; +export type { Stream }; +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]; + +const BROWSER_UA = + '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'; + +function buildSources(http: HttpClient, enabled: ReadonlyArray) { + const set = new Set(enabled); + const browserHttp = http.withHeaders({ 'User-Agent': BROWSER_UA }); + const all = [ + new AnilistSource(http), + new MalSource(http), + new KitsuSource(http), + new AllmangaSource(browserHttp), + new MegaPlaySource(browserHttp), + new AnimeParadiseSource(http), + new AnikotoSource(browserHttp), + new GogoanimeSource(browserHttp), + new GoyabuSource(browserHttp), + new MangadexSource(http), + new MangapillSource(http), + new WeebcentralSource(browserHttp), + ]; + 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}`); + const result = await src.info(decoded.r, { signal: opts?.signal }); + this.registry.cacheMedia(result); + return result; + } + + 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; + this.registry.cacheMedia(m); + 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; + this.registry.cacheMedia(m); + return this.registry.mergeChapters(m, { + signal: opts?.signal, + cursor: opts?.cursor, + limit: opts?.limit, + }); + } + + stream(episode: Episode | string, opts?: { signal?: AbortSignal }): ProgressiveResult { + if (typeof episode !== 'string') { + return this.registry.streamEpisode(episode, { signal: opts?.signal }); + } + return this.registry.streamFromSource(episode, { 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/src/server/cli.ts b/src/server/cli.ts new file mode 100644 index 0000000..6071e90 --- /dev/null +++ b/src/server/cli.ts @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import { startServer, ServerOptions } from './index.js'; +import { createSdk } from '../sdk.js'; +import type { SdkOptions } from '../config.js'; + +function parseEnv(): { port: number; sdkOpts: SdkOptions; serverOpts: ServerOptions } { + const port = Number(process.env.PORT ?? 3030); + const disabled = process.env.SOURCES_DISABLED + ? process.env.SOURCES_DISABLED.split(',').map((s) => s.trim()) + : undefined; + + // Proxy enables when any PROXY_* env var is set, or when the user explicitly + // opts in with PROXY=1. + const proxyEnabled = + process.env.PROXY === '1' || + process.env.PROXY === 'true' || + !!process.env.PROXY_SIGN_SECRET || + !!process.env.PROXY_ALLOWED_HOSTS || + !!process.env.PROXY_BASE; + + const serverOpts: ServerOptions = { port }; + if (proxyEnabled) { + serverOpts.proxy = { + base: process.env.PROXY_BASE, + signSecret: process.env.PROXY_SIGN_SECRET, + allowedHosts: process.env.PROXY_ALLOWED_HOSTS + ? process.env.PROXY_ALLOWED_HOSTS.split(',') + .map((s) => s.trim()) + .filter(Boolean) + : undefined, + }; + } + + return { port, sdkOpts: { disabled }, serverOpts }; +} + +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,y Comma-separated source IDs to disable', + ' PROXY=1 Enable /proxy + Stream URL rewriting', + ' PROXY_BASE=https://... Public base URL (defaults to Host header)', + ' PROXY_SIGN_SECRET=x HMAC-sign upstream URLs (recommended in prod)', + ' PROXY_ALLOWED_HOSTS=a,b SSRF allowlist (suffix-matched hostnames)', + '', + ].join('\n'), + ); + process.exit(0); +} + +const { port, sdkOpts, serverOpts } = parseEnv(); +const sdk = createSdk(sdkOpts); +const server = startServer({ ...serverOpts, 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..b19f265 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,1300 +1,65 @@ import * as http from 'node:http'; -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import * as nodeCrypto from 'node:crypto'; -import { Readable } from 'node:stream'; -import { BaseProvider } from '../providers/BaseProvider.js'; -import { BaseMetadataProvider, BrowseKind } from '../meta/BaseMetadataProvider.js'; -import { - ContentLanguage, - IUnitTracks, - MediaCatalogType, - MediaFormat, - MediaSeason, - ResolvedMediaStream, - SdkCache, -} from '../types/index.js'; -import { proxifySubtitleUrl } from '../utils/subtitles.js'; -import { strictUnwrapUrn } from '../utils/urn.js'; -import { - downloadVideo, - downloadMangaPage, - downloadMangaChapter, - detectImageExtension, -} from '../download/index.js'; +import { createSdk, Sdk } from '../sdk.js'; +import { buildRoutes, matchRoute } from './routes.js'; +import { handleProxyRequest, ProxyOptions } from './proxy.js'; export interface ServerOptions { - providers: BaseProvider[]; - /** - * Metadata providers exposed under `/meta/*`. AniList, MAL (Jikan), and - * Kitsu providers all implement {@link BaseMetadataProvider} — pass any - * combination, and `/meta/*` callers select with `?provider=`. - */ - metaProviders?: BaseMetadataProvider[]; port?: number; - auth?: { token: string }; - /** - * Enable the `/proxy` endpoint and automatically rewrite stream `sourceUrl` values - * to go through it — so browsers can play streams that require custom headers. - */ - proxy?: boolean; - /** - * Explicit `proxyBase` URL. When omitted (default), the server derives - * the base from each incoming request's `Host` header — so the SDK - * works behind reverse proxies / on cloud hosts without configuration. - * Set this when the public URL differs from what `Host` reports - * (e.g. `https://api.example.com` proxied to an internal `:3000`). - */ - proxyBase?: string; - /** - * When set, `/proxy` requires every `url` query param to be accompanied - * by an HMAC-SHA256 signature in `sig` (computed over `url` and `h`, - * keyed by this secret, hex-encoded). The proxy rewriter in this server - * automatically signs URLs it emits, so most callers don't need to do - * anything beyond setting this option. Unsigned/invalid-signature - * requests are rejected with 401. - */ - proxySignSecret?: string; - /** - * Optional allowlist of upstream hostnames the `/proxy` endpoint is - * permitted to fetch. Each entry is matched as a *suffix* of the target - * URL's hostname, so `"wixstatic.com"` covers `static.wixstatic.com` - * and friends. When set, targets outside the list are rejected with 403 - * — defends against SSRF (the proxy otherwise turns the server into an - * open HTTP relay). When omitted, all hosts are allowed. - */ - proxyAllowedHosts?: string[]; + sdk?: Sdk; /** - * Optional read/write cache for provider responses. When set, `/search`, - * `/content`, `/stream`, `/tracks`, and `/meta/*` results are looked up - * by a stable key before invoking the provider. See {@link SdkCache} for - * the contract and the key namespacing used. + * When set, exposes `/proxy` and rewrites every `Stream.url` / + * `Pages.pages[].url` / subtitle URL in responses to route through it. + * Browsers can then play streams that require custom headers or that + * the CDN refuses to serve cross-origin. */ - cache?: SdkCache; -} - -const CORS = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': '*', - 'Access-Control-Expose-Headers': '*', -}; - -function json(res: http.ServerResponse, status: number, body: unknown): void { - const payload = JSON.stringify(body); - res.writeHead(status, { - ...CORS, - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(payload), - }); - res.end(payload); -} - -function err(res: http.ServerResponse, status: number, message: string): void { - json(res, status, { error: message }); -} - -function timingSafeEquals(a: string, b: string): boolean { - if (a.length !== b.length) return false; - try { - return nodeCrypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); - } catch { - return false; - } -} - -function computeProxySignature( - targetUrl: string, - hParam: string | undefined, - secret: string, -): string { - const h = nodeCrypto.createHmac('sha256', secret); - h.update(targetUrl); - if (hParam) h.update('|h=' + hParam); - return h.digest('hex'); -} - -function buildProxyUrl( - proxyBase: string, - targetUrl: string, - hParam: string | undefined, - secret: string | undefined, -): string { - const parts = [`url=${encodeURIComponent(targetUrl)}`]; - if (hParam) parts.push(`h=${encodeURIComponent(hParam)}`); - if (secret) parts.push(`sig=${computeProxySignature(targetUrl, hParam, secret)}`); - return `${proxyBase}?${parts.join('&')}`; -} - -/** - * Rewrite every URI in an HLS manifest so each segment/key/sub-playlist - * is fetched through the proxy endpoint, preserving the original headers param. - */ -function rewriteHls(manifest: string, baseUrl: string, proxyBase: string, hParam?: string): string { - const h = hParam ? `&h=${encodeURIComponent(hParam)}` : ''; - const wrap = (uri: string) => { - try { - const abs = new URL(uri, baseUrl).href; - return `${proxyBase}?url=${encodeURIComponent(abs)}${h}`; - } catch { - return uri; - } - }; - return manifest - .split(/\r?\n/) - .map((line) => { - const t = line.trim(); - if (!t) return line; - if (t.startsWith('#')) - return t.replace(/URI=(["'])(.*?)\1/g, (_, q, u) => `URI=${q}${wrap(u)}${q}`); - return wrap(t); - }) - .join('\n'); + proxy?: ProxyOptions; } -/** - * Rewrite video stream `sourceUrl` fields (and subtitle URLs) to route through - * the proxy, with any required headers encoded in the `h` query param. - */ -function proxyifyStream( - stream: ResolvedMediaStream, - proxyBase: string, - signSecret: string | undefined, -): ResolvedMediaStream { - if (stream.type === 'manga') { - const hParam = - stream.pages.headers && Object.keys(stream.pages.headers).length > 0 - ? Buffer.from(JSON.stringify(stream.pages.headers)).toString('base64') - : undefined; - return { - type: 'manga', - pages: { - ...stream.pages, - imageUrls: stream.pages.imageUrls.map((url) => - buildProxyUrl(proxyBase, url, hParam, signSecret), - ), - }, - }; - } - - if (stream.type !== 'video') return stream; - return { - type: 'video', - streams: stream.streams.map((s) => { - const hParam = - s.headers && Object.keys(s.headers).length > 0 - ? Buffer.from(JSON.stringify(s.headers)).toString('base64') - : undefined; - const subtitles = s.subtitles?.map((t) => ({ - ...t, - url: proxifySubtitleUrl(proxyBase, t, { headers: s.headers, signSecret }), - })); - return { - ...s, - sourceUrl: buildProxyUrl(proxyBase, s.sourceUrl, hParam, signSecret), - ...(subtitles ? { subtitles } : {}), - }; - }), - }; -} - -/** Wrap the subtitle URLs returned by `fetchUnitTracks` through `/proxy`. */ -function proxyifyTracks( - tracks: IUnitTracks, - proxyBase: string, - signSecret: string | undefined, -): IUnitTracks { - return { - ...tracks, - subtitles: tracks.subtitles.map((t) => ({ - ...t, - url: proxifySubtitleUrl(proxyBase, t, { headers: tracks.headers, signSecret }), - })), - }; -} - -export function startServer(options: ServerOptions): http.Server { - const { - providers, - metaProviders = [], - port = 3000, - auth, - proxy = false, - cache, - proxyBase: configuredProxyBase, - proxySignSecret, - proxyAllowedHosts, - } = options; - - // Token → completed download, cleaned up after 10 minutes or on serve - const pendingDownloads = new Map< - string, - { - filePath: string; - tmpDir: string; - filename: string; - } - >(); - - function storePending(filePath: string, tmpDir: string, filename: string): string { - const token = crypto.randomUUID(); - pendingDownloads.set(token, { filePath, tmpDir, filename }); - setTimeout( - () => { - const info = pendingDownloads.get(token); - if (info) { - try { - fs.unlinkSync(info.filePath); - } catch { - /* ignore */ - } - try { - fs.rmdirSync(info.tmpDir); - } catch { - /* ignore */ - } - pendingDownloads.delete(token); - } - }, - 10 * 60 * 1000, - ); - return token; - } - - function servePending( - res: http.ServerResponse, - token: string | null, - contentType: string, - ): boolean { - if (!token) { - err(res, 400, 'Missing param: token'); - return true; - } - const info = pendingDownloads.get(token); - if (!info) { - err(res, 404, 'Download expired or not found'); - return true; - } - pendingDownloads.delete(token); - let stat: fs.Stats; - try { - stat = fs.statSync(info.filePath); - } catch { - err(res, 500, 'File missing after download'); - return true; - } - res.writeHead(200, { - ...CORS, - 'Content-Type': contentType, - 'Content-Length': stat.size, - 'Content-Disposition': `attachment; filename="${info.filename}"`, - }); - const rs = fs.createReadStream(info.filePath); - const cleanup = () => { - try { - fs.unlinkSync(info.filePath); - } catch { - /* ignore */ - } - try { - fs.rmdirSync(info.tmpDir); - } catch { - /* ignore */ - } - }; - rs.on('end', cleanup); - rs.on('error', cleanup); - rs.pipe(res); - return true; - } - - function openSse(res: http.ServerResponse): (data: unknown) => void { - res.writeHead(200, { - ...CORS, - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }); - return (data) => { - if (!res.writableEnded) res.write(`data: ${JSON.stringify(data)}\n\n`); - }; - } +export type { ProxyOptions }; - async function cached(key: string, compute: () => Promise): Promise { - if (!cache) return compute(); - const hit = await cache.get(key); - if (hit !== undefined) return hit as T; - const value = await compute(); - await cache.set(key, value); - return value; - } +export function startServer(opts: ServerOptions = {}): http.Server { + const sdk = opts.sdk ?? createSdk(); + const routes = buildRoutes(sdk, opts.proxy); - const server = http.createServer(async (req, res) => { - const url = new URL(req.url ?? '/', `http://localhost`); - const q = url.searchParams; - // Derive the public base from the configured override or the Host header - // so URLs the SDK rewrites are reachable from the same host the caller - // is using. - const hostHeader = req.headers.host ?? `localhost:${port}`; - const scheme = - (req.headers['x-forwarded-proto'] as string | undefined)?.split(',')[0]?.trim() ?? 'http'; - const proxyBase = configuredProxyBase ?? `${scheme}://${hostHeader}/proxy`; + const server = http.createServer((req, res) => { + const u = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + const method = req.method ?? 'GET'; - if (req.method === 'OPTIONS') { - res.writeHead(204, CORS); + if (method === 'OPTIONS') { + res.writeHead(204, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': '*', + }); res.end(); return; } - if (auth) { - const header = req.headers['authorization'] ?? ''; - const token = header.startsWith('Bearer ') ? header.slice(7) : ''; - if (token !== auth.token) return err(res, 401, 'Unauthorized'); - } - - if (req.method !== 'GET') return err(res, 405, 'Method not allowed'); - - // ── Discovery ──────────────────────────────────────────────────────── - if (url.pathname === '/openapi.json') { - const spec = buildOpenApiSpec({ - providerIds: providers.map((p) => p.id), - metaProviderIds: metaProviders.map((p) => p.id), - proxy, - proxyBase, + if (opts.proxy && u.pathname === '/proxy') { + handleProxyRequest(req, res, u.searchParams, opts.proxy).catch((e) => { + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (e as Error).message })); + } }); - return json(res, 200, spec); + return; } - if (url.pathname === '/health') { - return json(res, 200, { - ok: true, - providers: providers.map((p) => p.id), - metaProviders: metaProviders.map((p) => p.id), - proxy, - }); + 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 findProvider = (id: string | null): BaseProvider | null => - id ? (providers.find((p) => p.id === id) ?? null) : null; - - const findMetaProvider = (id: string | null): BaseMetadataProvider | null => - id ? (metaProviders.find((p) => p.id === id) ?? null) : null; - - try { - // ── Proxy ────────────────────────────────────────────────────────── - if (url.pathname === '/proxy') { - if (!proxy) - return err(res, 404, 'Proxy not enabled — set proxy: true in startServer options'); - - const targetUrl = q.get('url'); - if (!targetUrl) return err(res, 400, 'Missing param: url'); - - // SSRF guard - if (proxyAllowedHosts && proxyAllowedHosts.length > 0) { - let targetHost: string; - try { - targetHost = new URL(targetUrl).hostname; - } catch { - return err(res, 400, 'Invalid url'); - } - const ok = proxyAllowedHosts.some( - (h) => targetHost === h || targetHost.endsWith(`.${h}`), - ); - if (!ok) return err(res, 403, `Target host ${targetHost} not in allowlist`); - } - - const hParam = q.get('h'); - if (proxySignSecret) { - const sig = q.get('sig'); - if (!sig) return err(res, 401, 'Missing required `sig` query parameter'); - const expected = computeProxySignature(targetUrl, hParam ?? undefined, proxySignSecret); - if (!timingSafeEquals(sig, expected)) { - return err(res, 401, 'Invalid proxy signature'); - } - } - const upstreamHeaders: Record = { - Accept: '*/*', - 'User-Agent': - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', - 'Accept-Language': 'en-US,en;q=0.9', - 'Accept-Encoding': 'identity', - }; - if (hParam) { - try { - Object.assign( - upstreamHeaders, - JSON.parse(Buffer.from(hParam, 'base64').toString('utf8')), - ); - } catch { - /* ignore malformed headers param */ - } - } - // Forward Range header for video seeking - if (req.headers.range) upstreamHeaders['Range'] = req.headers.range; - - // Abort the upstream fetch when the client disconnects to avoid leaking connections - const abortCtrl = new AbortController(); - req.on('close', () => abortCtrl.abort()); - - let upstream: Response; - try { - upstream = await fetch(targetUrl, { - headers: upstreamHeaders, - redirect: 'follow', - signal: abortCtrl.signal, - }); - } catch (fetchErr) { - const msg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr); - console.error(`Proxy fetch failed for ${targetUrl}: ${msg}`); - return err(res, 502, `Upstream fetch failed: ${msg}`); - } - - if (!upstream.ok) { - const text = await upstream.text().catch(() => 'No body'); - console.error( - `Proxy upstream error ${upstream.status} for ${targetUrl}: ${text.slice(0, 200)}`, - ); - return err( - res, - upstream.status === 404 ? 404 : 502, - `Upstream returned ${upstream.status}: ${text.slice(0, 100)}`, - ); - } - - const ct = upstream.headers.get('content-type') ?? ''; - - // Detect HLS by Content-Type, URL extension, or body peek (#EXTM3U) - const looksLikeHls = - ct.toLowerCase().includes('mpegurl') || targetUrl.split('?')[0].endsWith('.m3u8'); - - if (looksLikeHls) { - const text = await upstream.text(); - // Also check by body content in case Content-Type is wrong - if (!text.trim().startsWith('#EXTM3U') && !looksLikeHls) { - // Not actually an HLS manifest — fall through to stream it - } else { - const rewritten = rewriteHls(text, targetUrl, proxyBase, hParam ?? undefined); - const buf = Buffer.from(rewritten, 'utf8'); - res.writeHead(upstream.status, { - ...CORS, - 'Content-Type': 'application/vnd.apple.mpegurl', - 'Content-Length': buf.length, - }); - res.end(buf); - return; - } - } - - // For non-HLS (segments, MP4, etc.) — stream without buffering - // Some CDNs disguise .ts segments as image/* or text/* — override the type - const ctOverride = q.get('ct'); - let contentType = ct || 'application/octet-stream'; - if (ctOverride) { - contentType = ctOverride; - } else if ( - targetUrl.split('?')[0].toLowerCase().endsWith('.ts') && - (ct.startsWith('image/') || (ct.startsWith('text/') && !ct.includes('html'))) - ) { - contentType = 'video/mp2t'; - } - // mp4upload and similar CDNs return application/octet-stream for .mp4 files - if ( - contentType === 'application/octet-stream' && - targetUrl.split('?')[0].toLowerCase().endsWith('.mp4') - ) { - contentType = 'video/mp4'; - } - - const outHeaders: Record = { ...CORS, 'Content-Type': contentType }; - const cl = upstream.headers.get('content-length'); - if (cl) outHeaders['Content-Length'] = cl; - const cr = upstream.headers.get('content-range'); - if (cr) outHeaders['Content-Range'] = cr; - const ar = upstream.headers.get('accept-ranges'); - outHeaders['Accept-Ranges'] = ar ?? 'bytes'; - - res.writeHead(upstream.status, outHeaders); - - if (upstream.body) { - const readable = Readable.fromWeb( - upstream.body as Parameters[0], - ); - readable.on('error', () => {}); - res.on('close', () => readable.destroy()); - readable.pipe(res); - } else { - res.end(); - } - 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 })); } - - // ── API ──────────────────────────────────────────────────────────── - // Each handler runs its provider call through the optional `cache`. - // Keys are namespaced by endpoint + provider so the consumer's cache - // can apply different TTLs per kind if it wants to. - if (url.pathname === '/search') { - const query = q.get('q'); - const provider = findProvider(q.get('provider')); - if (!query) return err(res, 400, 'Missing param: q'); - if (!provider) return err(res, 400, 'Missing or unknown param: provider'); - const items = await cached(`search:${provider.id}:${query}`, () => provider.search(query)); - return json(res, 200, items); - } - - if (url.pathname === '/content') { - const mediaId = q.get('mediaId'); - const provider = findProvider(q.get('provider')); - if (!mediaId) return err(res, 400, 'Missing param: mediaId'); - if (!provider) return err(res, 400, 'Missing or unknown param: provider'); - // One call returns all episodes; each unit advertises its available - // translations. Callers pick the language at /stream time. - const units = await cached(`content:${provider.id}:${mediaId}`, () => - provider.fetchContentUnits(mediaId), - ); - return json(res, 200, units); - } - - if (url.pathname === '/stream') { - const unitId = q.get('unitId'); - const provider = findProvider(q.get('provider')); - const language = q.get('language') as ContentLanguage | null; - if (!unitId) return err(res, 400, 'Missing param: unitId'); - if (!provider) return err(res, 400, 'Missing or unknown param: provider'); - let stream = await cached(`stream:${provider.id}:${unitId}:${language ?? ''}`, () => - provider.resolveStream(unitId, language ?? undefined), - ); - if (proxy) stream = proxyifyStream(stream, proxyBase, proxySignSecret); - return json(res, 200, stream); - } - - if (url.pathname === '/tracks') { - const unitId = q.get('unitId'); - const provider = findProvider(q.get('provider')); - const language = q.get('language') as ContentLanguage | null; - if (!unitId) return err(res, 400, 'Missing param: unitId'); - if (!provider) return err(res, 400, 'Missing or unknown param: provider'); - // Only the cheap metadata path. Providers without `fetchUnitTracks` - // return 501 — clients should fall back to /stream's subtitle info - // rather than pay the resolveStream cost twice. - if (!provider.fetchUnitTracks) { - return err( - res, - 501, - `Provider "${provider.id}" does not expose track metadata; read subtitles from /stream instead`, - ); - } - let tracks = await cached(`tracks:${provider.id}:${unitId}:${language ?? ''}`, () => - provider.fetchUnitTracks!(unitId, language ?? undefined), - ); - if (proxy) tracks = proxyifyTracks(tracks, proxyBase, proxySignSecret); - return json(res, 200, tracks); - } - - // ── Download: Video — SSE progress ─────────────────────────────── - if (url.pathname === '/download/video/progress') { - const unitId = q.get('unitId'); - const provider = findProvider(q.get('provider')); - const language = q.get('language') as ContentLanguage | null; - if (!unitId) return err(res, 400, 'Missing param: unitId'); - if (!provider) return err(res, 400, 'Missing or unknown param: provider'); - - const send = openSse(res); - try { - send({ type: 'progress', phase: 'resolving', detail: 'Resolving stream…' }); - const stream = await cached(`stream:${provider.id}:${unitId}:${language ?? ''}`, () => - provider.resolveStream(unitId, language ?? undefined), - ); - if (stream.type !== 'video') { - send({ type: 'error', message: `Content is not video (type: ${stream.type})` }); - res.end(); - return; - } - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'anime-sdk-dl-')); - const safeUnit = unitId.replace(/[^a-zA-Z0-9_-]/g, '_'); - const filename = `${provider.id}_${safeUnit}.mp4`; - const tmpFile = path.join(tmpDir, filename); - try { - await downloadVideo(stream.streams, tmpFile, { - timeoutMs: 1_200_000, - onProgress: ({ phase, detail }) => send({ type: 'progress', phase, detail }), - }); - send({ type: 'complete', token: storePending(tmpFile, tmpDir, filename) }); - } catch (dlErr) { - try { - fs.unlinkSync(tmpFile); - } catch { - /* ignore */ - } - try { - fs.rmdirSync(tmpDir); - } catch { - /* ignore */ - } - send({ - type: 'error', - message: dlErr instanceof Error ? dlErr.message : String(dlErr), - }); - } - } catch (e) { - send({ type: 'error', message: e instanceof Error ? e.message : String(e) }); - } - res.end(); - return; - } - - // ── Download: Video — serve completed file ──────────────────────── - if (url.pathname === '/download/video/file') { - servePending(res, q.get('token'), 'video/mp4'); - return; - } - - // ── Download: Manga Chapter — SSE progress ──────────────────────── - if (url.pathname === '/download/manga/chapter/progress') { - const unitId = q.get('unitId'); - const provider = findProvider(q.get('provider')); - if (!unitId) return err(res, 400, 'Missing param: unitId'); - if (!provider) return err(res, 400, 'Missing or unknown param: provider'); - - const send = openSse(res); - try { - send({ type: 'progress', downloaded: 0, total: 0 }); - const stream = await cached(`stream:${provider.id}:${unitId}:`, () => - provider.resolveStream(unitId), - ); - if (stream.type !== 'manga') { - send({ type: 'error', message: `Content is not manga (type: ${stream.type})` }); - res.end(); - return; - } - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'anime-sdk-dl-')); - const safeUnit = unitId.replace(/[^a-zA-Z0-9_-]/g, '_'); - const filename = `${provider.id}_${safeUnit}.zip`; - const tmpFile = path.join(tmpDir, filename); - try { - await downloadMangaChapter(stream.pages, tmpFile, { - onProgress: ({ downloaded, total }) => send({ type: 'progress', downloaded, total }), - }); - send({ type: 'complete', token: storePending(tmpFile, tmpDir, filename) }); - } catch (dlErr) { - try { - fs.unlinkSync(tmpFile); - } catch { - /* ignore */ - } - try { - fs.rmdirSync(tmpDir); - } catch { - /* ignore */ - } - send({ - type: 'error', - message: dlErr instanceof Error ? dlErr.message : String(dlErr), - }); - } - } catch (e) { - send({ type: 'error', message: e instanceof Error ? e.message : String(e) }); - } - res.end(); - return; - } - - // ── Download: Manga Chapter — serve completed file ──────────────── - if (url.pathname === '/download/manga/chapter/file') { - servePending(res, q.get('token'), 'application/zip'); - return; - } - - // ── Download: Video ─────────────────────────────────────────────── - if (url.pathname === '/download/video') { - const unitId = q.get('unitId'); - const provider = findProvider(q.get('provider')); - const language = q.get('language') as ContentLanguage | null; - if (!unitId) return err(res, 400, 'Missing param: unitId'); - if (!provider) return err(res, 400, 'Missing or unknown param: provider'); - - let stream = await cached(`stream:${provider.id}:${unitId}:${language ?? ''}`, () => - provider.resolveStream(unitId, language ?? undefined), - ); - if (stream.type !== 'video') { - return err(res, 400, `Content is not video (type: ${stream.type})`); - } - - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'anime-sdk-dl-')); - const tmpFile = path.join(tmpDir, `${provider.id}_${unitId.replace(/\//g, '_')}.mp4`); - - try { - await downloadVideo(stream.streams, tmpFile, { timeoutMs: 1_200_000 }); - - const stat = fs.statSync(tmpFile); - const safeUnit = unitId.replace(/[^a-zA-Z0-9_-]/g, '_'); - res.writeHead(200, { - ...CORS, - 'Content-Type': 'video/mp4', - 'Content-Length': stat.size, - 'Content-Disposition': `attachment; filename="${provider.id}_${safeUnit}.mp4"`, - }); - - const readStream = fs.createReadStream(tmpFile); - readStream.pipe(res); - readStream.on('end', () => { - try { - fs.unlinkSync(tmpFile); - fs.rmdirSync(tmpDir); - } catch { - /* ignore */ - } - }); - readStream.on('error', () => { - try { - fs.unlinkSync(tmpFile); - fs.rmdirSync(tmpDir); - } catch { - /* ignore */ - } - }); - } catch (dlErr) { - try { - fs.unlinkSync(tmpFile); - fs.rmdirSync(tmpDir); - } catch { - /* ignore */ - } - throw dlErr; - } - return; - } - - // ── Download: Manga Page ──────────────────────────────────────────── - if (url.pathname === '/download/manga/page') { - const unitId = q.get('unitId'); - const provider = findProvider(q.get('provider')); - const pageParam = q.get('page'); - if (!unitId) return err(res, 400, 'Missing param: unitId'); - if (!provider) return err(res, 400, 'Missing or unknown param: provider'); - - const pageIndex = pageParam !== null ? parseInt(pageParam, 10) : 0; - if (isNaN(pageIndex) || pageIndex < 0) { - return err(res, 400, 'Invalid page index'); - } - - let stream = await cached(`stream:${provider.id}:${unitId}:`, () => - provider.resolveStream(unitId), - ); - if (stream.type !== 'manga') { - return err(res, 400, `Content is not manga (type: ${stream.type})`); - } - if (pageIndex >= stream.pages.imageUrls.length) { - return err( - res, - 400, - `Page index ${pageIndex} out of range (0-${stream.pages.imageUrls.length - 1})`, - ); - } - - // Proxy the image to the client - const imgUrl = stream.pages.imageUrls[pageIndex]; - const imgHeaders: Record = { - 'User-Agent': - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', - ...(stream.pages.headers ?? {}), - }; - - const abortCtrl = new AbortController(); - req.on('close', () => abortCtrl.abort()); - - const upstream = await fetch(imgUrl, { - headers: imgHeaders, - redirect: 'follow', - signal: abortCtrl.signal, - }); - if (!upstream.ok) { - return err(res, 502, `Upstream returned ${upstream.status}`); - } - - const ct = upstream.headers.get('content-type') ?? 'image/jpeg'; - const ext = detectImageExtension(ct); - const paddedPage = String(pageIndex + 1).padStart(3, '0'); - const safeUnit = unitId.replace(/[^a-zA-Z0-9_-]/g, '_'); - - const outHeaders: Record = { - ...CORS, - 'Content-Type': ct, - 'Content-Disposition': `attachment; filename="${provider.id}_${safeUnit}_page_${paddedPage}${ext}"`, - }; - const cl = upstream.headers.get('content-length'); - if (cl) outHeaders['Content-Length'] = cl; - - res.writeHead(200, outHeaders); - if (upstream.body) { - const readable = Readable.fromWeb( - upstream.body as Parameters[0], - ); - readable.on('error', () => {}); - res.on('close', () => readable.destroy()); - readable.pipe(res); - } else { - res.end(); - } - return; - } - - // ── Download: Manga Chapter (ZIP) ────────────────────────────────── - if (url.pathname === '/download/manga/chapter') { - const unitId = q.get('unitId'); - const provider = findProvider(q.get('provider')); - if (!unitId) return err(res, 400, 'Missing param: unitId'); - if (!provider) return err(res, 400, 'Missing or unknown param: provider'); - - let stream = await cached(`stream:${provider.id}:${unitId}:`, () => - provider.resolveStream(unitId), - ); - if (stream.type !== 'manga') { - return err(res, 400, `Content is not manga (type: ${stream.type})`); - } - - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'anime-sdk-dl-')); - const tmpFile = path.join(tmpDir, `${provider.id}_${unitId.replace(/\//g, '_')}.zip`); - - try { - await downloadMangaChapter(stream.pages, tmpFile, { timeoutMs: 300_000 }); - - const stat = fs.statSync(tmpFile); - const safeUnit = unitId.replace(/[^a-zA-Z0-9_-]/g, '_'); - res.writeHead(200, { - ...CORS, - 'Content-Type': 'application/zip', - 'Content-Length': stat.size, - 'Content-Disposition': `attachment; filename="${provider.id}_${safeUnit}.zip"`, - }); - - const readStream = fs.createReadStream(tmpFile); - readStream.pipe(res); - readStream.on('end', () => { - try { - fs.unlinkSync(tmpFile); - fs.rmdirSync(tmpDir); - } catch { - /* ignore */ - } - }); - readStream.on('error', () => { - try { - fs.unlinkSync(tmpFile); - fs.rmdirSync(tmpDir); - } catch { - /* ignore */ - } - }); - } catch (dlErr) { - try { - fs.unlinkSync(tmpFile); - fs.rmdirSync(tmpDir); - } catch { - /* ignore */ - } - throw dlErr; - } - return; - } - - // ── Metadata layer ─────────────────────────────────────────────── - // Routes: - // /meta/search ?provider=anilist&q= - // /meta/info ?provider=anilist&id= - // /meta/content ?provider=anilist&id=&contentProvider= - // /meta/stream ?provider=anilist&id=&episode=&contentProvider=[&language=] - // /meta/tracks ?provider=anilist&id=&episode=&contentProvider=[&language=] - // /meta/browse ?provider=anilist&kind=trending|popular|seasonal|top[&catalogType=&page=&perPage=&season=&year=&format=] - if (url.pathname.startsWith('/meta/')) { - const meta = findMetaProvider(q.get('provider')); - if (!meta) return err(res, 400, 'Missing or unknown param: provider'); - - if (url.pathname === '/meta/search') { - const query = q.get('q'); - if (!query) return err(res, 400, 'Missing param: q'); - const items = await cached(`meta:search:${meta.id}:${query}`, () => meta.search(query)); - return json(res, 200, items); - } - - if (url.pathname === '/meta/info') { - const id = q.get('id'); - if (!id) return err(res, 400, 'Missing param: id'); - try { - strictUnwrapUrn(meta.id, id); - } catch (e) { - return err(res, 400, e instanceof Error ? e.message : String(e)); - } - const info = await cached(`meta:info:${meta.id}:${id}`, () => meta.fetchMediaInfo(id)); - return json(res, 200, info); - } - - const contentProvider = findProvider(q.get('contentProvider')); - - if (url.pathname === '/meta/content') { - const id = q.get('id'); - if (!id) return err(res, 400, 'Missing param: id'); - if (!contentProvider) return err(res, 400, 'Missing or unknown param: contentProvider'); - const units = await cached(`meta:content:${meta.id}:${id}:${contentProvider.id}`, () => - meta.fetchContentUnits(id, contentProvider), - ); - return json(res, 200, units); - } - - if (url.pathname === '/meta/stream') { - const id = q.get('id'); - const episode = q.get('episode'); - const language = q.get('language') as ContentLanguage | null; - if (!id) return err(res, 400, 'Missing param: id'); - if (!episode) return err(res, 400, 'Missing param: episode'); - if (!contentProvider) return err(res, 400, 'Missing or unknown param: contentProvider'); - const epNum = parseFloat(episode); - if (!Number.isFinite(epNum)) return err(res, 400, 'Param `episode` must be numeric'); - let stream = await cached( - `meta:stream:${meta.id}:${id}:${contentProvider.id}:${epNum}:${language ?? ''}`, - () => meta.resolveStream(id, epNum, contentProvider, language ?? undefined), - ); - if (proxy) stream = proxyifyStream(stream, proxyBase, proxySignSecret); - return json(res, 200, stream); - } - - if (url.pathname === '/meta/tracks') { - const id = q.get('id'); - const episode = q.get('episode'); - const language = q.get('language') as ContentLanguage | null; - if (!id) return err(res, 400, 'Missing param: id'); - if (!episode) return err(res, 400, 'Missing param: episode'); - if (!contentProvider) return err(res, 400, 'Missing or unknown param: contentProvider'); - if (!contentProvider.supportsUnitTracks) { - return err(res, 501, `Provider "${contentProvider.id}" does not expose track metadata`); - } - const epNum = parseFloat(episode); - if (!Number.isFinite(epNum)) return err(res, 400, 'Param `episode` must be numeric'); - let tracks = await cached( - `meta:tracks:${meta.id}:${id}:${contentProvider.id}:${epNum}:${language ?? ''}`, - () => meta.fetchUnitTracks(id, epNum, contentProvider, language ?? undefined), - ); - if (proxy) tracks = proxyifyTracks(tracks, proxyBase, proxySignSecret); - return json(res, 200, tracks); - } - - if (url.pathname === '/meta/browse') { - const kind = q.get('kind') as BrowseKind | null; - if (!kind || !['trending', 'popular', 'seasonal', 'top'].includes(kind)) { - return err(res, 400, 'Param `kind` must be one of: trending, popular, seasonal, top'); - } - if (!meta.supportsBrowseKind(kind)) { - return err(res, 501, `Provider "${meta.id}" does not support browse('${kind}')`); - } - const catalogType = (q.get('catalogType') as MediaCatalogType | null) ?? 'ANIME'; - const page = q.get('page') ? Math.max(1, parseInt(q.get('page')!, 10) || 1) : 1; - const perPage = q.get('perPage') ? parseInt(q.get('perPage')!, 10) : undefined; - const season = q.get('season') as MediaSeason | null; - const year = q.get('year') ? parseInt(q.get('year')!, 10) : undefined; - const format = q.get('format') as MediaFormat | null; - const cacheKey = `meta:browse:${meta.id}:${kind}:${catalogType}:${page}:${perPage ?? ''}:${season ?? ''}:${year ?? ''}:${format ?? ''}`; - const items = await cached(cacheKey, () => - meta.browse(kind, { - catalogType, - page, - perPage, - season: season ?? undefined, - year, - format: format ?? undefined, - }), - ); - return json(res, 200, items); - } - - return err(res, 404, 'Not found'); - } - - return err(res, 404, 'Not found'); - } catch (e) { - console.log(e); - return err(res, 500, e instanceof Error ? e.message : String(e)); - } + }); }); - server.listen(port, () => console.log(`anime-sdk server listening on http://localhost:${port}`)); + server.listen(opts.port ?? 0); return server; } - -/** - * Build a minimal OpenAPI 3.1 spec describing every route the server - * exposes. Returned as a JSON object; the server serves it under - * `/openapi.json`. Tools like Swagger UI / Redoc can consume it directly. - */ -function buildOpenApiSpec(args: { - providerIds: string[]; - metaProviderIds: string[]; - proxy: boolean; - proxyBase: string; -}): Record { - const providerEnum = args.providerIds.length > 0 ? args.providerIds : ['']; - const metaEnum = args.metaProviderIds.length > 0 ? args.metaProviderIds : ['']; - const paths: Record = { - '/search': { - get: { - summary: 'Search a content provider for a title', - parameters: [ - { name: 'q', in: 'query', required: true, schema: { type: 'string' } }, - { - name: 'provider', - in: 'query', - required: true, - schema: { type: 'string', enum: providerEnum }, - }, - ], - responses: { '200': { description: 'IMediaSearchResult[]' } }, - }, - }, - '/content': { - get: { - summary: 'List episodes/chapters for a media URN', - parameters: [ - { name: 'mediaId', in: 'query', required: true, schema: { type: 'string' } }, - { - name: 'provider', - in: 'query', - required: true, - schema: { type: 'string', enum: providerEnum }, - }, - ], - responses: { '200': { description: 'IContentUnit[]' } }, - }, - }, - '/stream': { - get: { - summary: 'Resolve a playable stream for a unit URN', - parameters: [ - { name: 'unitId', in: 'query', required: true, schema: { type: 'string' } }, - { - name: 'provider', - in: 'query', - required: true, - schema: { type: 'string', enum: providerEnum }, - }, - { - name: 'language', - in: 'query', - schema: { type: 'string', enum: ['sub', 'dub', 'raw'] }, - }, - ], - responses: { '200': { description: 'ResolvedMediaStream' } }, - }, - }, - '/tracks': { - get: { - summary: 'Cheap-path: subtitles/qualities without resolving a stream', - parameters: [ - { name: 'unitId', in: 'query', required: true, schema: { type: 'string' } }, - { - name: 'provider', - in: 'query', - required: true, - schema: { type: 'string', enum: providerEnum }, - }, - { - name: 'language', - in: 'query', - schema: { type: 'string', enum: ['sub', 'dub', 'raw'] }, - }, - ], - responses: { - '200': { description: 'IUnitTracks' }, - '501': { description: 'Provider does not expose tracks' }, - }, - }, - }, - '/health': { - get: { summary: 'Health + capability check', responses: { '200': { description: 'OK' } } }, - }, - }; - if (args.metaProviderIds.length > 0) { - paths['/meta/search'] = { - get: { - summary: 'Search a metadata catalogue (AniList/MAL/Kitsu)', - parameters: [ - { name: 'q', in: 'query', required: true, schema: { type: 'string' } }, - { - name: 'provider', - in: 'query', - required: true, - schema: { type: 'string', enum: metaEnum }, - }, - ], - responses: { '200': { description: 'IMetaSearchResult[]' } }, - }, - }; - paths['/meta/info'] = { - get: { - summary: 'Full metadata for a meta URN (e.g. `anilist:21`)', - parameters: [ - { name: 'id', in: 'query', required: true, schema: { type: 'string' } }, - { - name: 'provider', - in: 'query', - required: true, - schema: { type: 'string', enum: metaEnum }, - }, - ], - responses: { '200': { description: 'IMediaMetadata' } }, - }, - }; - paths['/meta/content'] = { - get: { - summary: 'Episode list for a meta URN, resolved via a content provider', - parameters: [ - { name: 'id', in: 'query', required: true, schema: { type: 'string' } }, - { - name: 'provider', - in: 'query', - required: true, - schema: { type: 'string', enum: metaEnum }, - }, - { - name: 'contentProvider', - in: 'query', - required: true, - schema: { type: 'string', enum: providerEnum }, - }, - ], - responses: { '200': { description: 'IContentUnit[]' } }, - }, - }; - paths['/meta/stream'] = { - get: { - summary: 'Resolve a stream by episode number on a content provider', - parameters: [ - { name: 'id', in: 'query', required: true, schema: { type: 'string' } }, - { name: 'episode', in: 'query', required: true, schema: { type: 'number' } }, - { - name: 'provider', - in: 'query', - required: true, - schema: { type: 'string', enum: metaEnum }, - }, - { - name: 'contentProvider', - in: 'query', - required: true, - schema: { type: 'string', enum: providerEnum }, - }, - { - name: 'language', - in: 'query', - schema: { type: 'string', enum: ['sub', 'dub', 'raw'] }, - }, - ], - responses: { '200': { description: 'ResolvedMediaStream' } }, - }, - }; - paths['/meta/tracks'] = { - get: { - summary: 'Cheap-path: tracks for an episode, by meta URN + content provider', - parameters: [ - { name: 'id', in: 'query', required: true, schema: { type: 'string' } }, - { name: 'episode', in: 'query', required: true, schema: { type: 'number' } }, - { - name: 'provider', - in: 'query', - required: true, - schema: { type: 'string', enum: metaEnum }, - }, - { - name: 'contentProvider', - in: 'query', - required: true, - schema: { type: 'string', enum: providerEnum }, - }, - { - name: 'language', - in: 'query', - schema: { type: 'string', enum: ['sub', 'dub', 'raw'] }, - }, - ], - responses: { - '200': { description: 'IUnitTracks' }, - '501': { description: 'Provider does not expose tracks' }, - }, - }, - }; - paths['/meta/browse'] = { - get: { - summary: 'Browse the catalogue (trending/popular/seasonal/top)', - parameters: [ - { - name: 'kind', - in: 'query', - required: true, - schema: { type: 'string', enum: ['trending', 'popular', 'seasonal', 'top'] }, - }, - { - name: 'provider', - in: 'query', - required: true, - schema: { type: 'string', enum: metaEnum }, - }, - { - name: 'catalogType', - in: 'query', - schema: { type: 'string', enum: ['ANIME', 'MANGA'] }, - }, - { name: 'page', in: 'query', schema: { type: 'integer', minimum: 1 } }, - { name: 'perPage', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 50 } }, - { - name: 'season', - in: 'query', - schema: { type: 'string', enum: ['WINTER', 'SPRING', 'SUMMER', 'FALL'] }, - }, - { name: 'year', in: 'query', schema: { type: 'integer' } }, - { name: 'format', in: 'query', schema: { type: 'string' } }, - ], - responses: { - '200': { description: 'IMetaSearchResult[]' }, - '501': { description: 'Browse kind not supported' }, - }, - }, - }; - } - if (args.proxy) { - paths['/proxy'] = { - get: { - summary: 'CORS-friendly upstream proxy for stream/subtitle URLs', - parameters: [ - { name: 'url', in: 'query', required: true, schema: { type: 'string' } }, - { - name: 'h', - in: 'query', - schema: { type: 'string', description: 'base64-JSON headers' }, - }, - { - name: 'ct', - in: 'query', - schema: { type: 'string', description: 'Content-Type override' }, - }, - { - name: 'sig', - in: 'query', - schema: { - type: 'string', - description: 'HMAC signature (required when proxySignSecret is configured)', - }, - }, - ], - responses: { - '200': { description: 'Streamed upstream body' }, - '401': { description: 'Bad/missing signature' }, - '403': { description: 'Host not in allowlist' }, - }, - }, - }; - } - return { - openapi: '3.1.0', - info: { title: 'anime-sdk', version: '1.0.1', description: 'Universal media SDK server' }, - servers: [{ url: args.proxyBase.replace(/\/proxy$/, '') }], - paths, - }; -} diff --git a/src/server/proxy.ts b/src/server/proxy.ts new file mode 100644 index 0000000..901f7a2 --- /dev/null +++ b/src/server/proxy.ts @@ -0,0 +1,318 @@ +import * as crypto from 'node:crypto'; +import * as http from 'node:http'; +import { Readable } from 'node:stream'; +import type { Stream, Pages, Subtitle } from '../types.js'; + +export interface ProxyOptions { + /** + * Public base URL the proxy is reachable at. When omitted, derived per + * request from the incoming `Host` header (and `x-forwarded-proto`). + * Set when the public URL differs from what Node sees — e.g. behind a + * reverse proxy where Node listens on `:3030` but clients hit + * `https://api.example.com`. + */ + base?: string; + /** + * When set, the proxy requires every request to carry an HMAC-SHA256 + * `sig` parameter computed over the target URL (and the `h` payload + * when present). Unsigned requests return 401. The server signs the + * URLs it emits, so most consumers don't have to do anything beyond + * setting this option. + */ + signSecret?: string; + /** + * Suffix-matched hostname allowlist. When non-empty, the proxy refuses + * to fetch upstream URLs whose host isn't covered. Defends against SSRF + * — the proxy is otherwise an open HTTP relay. + */ + allowedHosts?: string[]; +} + +const CORS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': '*', + 'Access-Control-Expose-Headers': '*', +}; + +const UPSTREAM_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'; + +function timingSafeEquals(a: string, b: string): boolean { + if (a.length !== b.length) return false; + try { + return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); + } catch { + return false; + } +} + +function computeSignature(targetUrl: string, hParam: string | undefined, secret: string): string { + const h = crypto.createHmac('sha256', secret); + h.update(targetUrl); + if (hParam) h.update('|h=' + hParam); + return h.digest('hex'); +} + +function encodeHeaders(headers: Record | undefined): string | undefined { + if (!headers || Object.keys(headers).length === 0) return undefined; + return Buffer.from(JSON.stringify(headers)).toString('base64'); +} + +function deriveProxyBase(req: http.IncomingMessage, configured?: string): string { + if (configured) return configured.replace(/\/$/, ''); + const host = req.headers.host ?? 'localhost'; + const proto = + (req.headers['x-forwarded-proto'] as string | undefined)?.split(',')[0]?.trim() ?? 'http'; + return `${proto}://${host}`; +} + +/** Build a proxy URL pointing at `/proxy?url=...&h=...&sig=...`. */ +export function buildProxyUrl( + proxyBase: string, + targetUrl: string, + hParam: string | undefined, + signSecret: string | undefined, + contentType?: string, +): string { + const parts = [`url=${encodeURIComponent(targetUrl)}`]; + if (hParam) parts.push(`h=${encodeURIComponent(hParam)}`); + if (contentType) parts.push(`ct=${encodeURIComponent(contentType)}`); + if (signSecret) parts.push(`sig=${computeSignature(targetUrl, hParam, signSecret)}`); + return `${proxyBase}/proxy?${parts.join('&')}`; +} + +function proxifySubtitle( + proxyBase: string, + subtitle: Subtitle, + headers: Record | undefined, + signSecret: string | undefined, +): Subtitle { + const hParam = encodeHeaders(headers); + const ct = + subtitle.format === 'vtt' || /\.vtt(?:\?|$)/i.test(subtitle.url) ? 'text/vtt' : undefined; + return { ...subtitle, url: buildProxyUrl(proxyBase, subtitle.url, hParam, signSecret, ct) }; +} + +/** Rewrite every URL in a `Stream` to route through `/proxy`. */ +export function proxifyStream( + stream: Stream, + proxyBase: string, + signSecret: string | undefined, +): Stream { + const hParam = encodeHeaders(stream.headers); + return { + ...stream, + url: buildProxyUrl(proxyBase, stream.url, hParam, signSecret), + subtitles: stream.subtitles.map((s) => + proxifySubtitle(proxyBase, s, stream.headers, signSecret), + ), + }; +} + +/** Rewrite every page URL in a `Pages` to route through `/proxy`. */ +export function proxifyPages( + pages: Pages, + proxyBase: string, + signSecret: string | undefined, + headers?: Record, +): Pages { + const hParam = encodeHeaders(headers); + return { + ...pages, + pages: pages.pages.map((p) => ({ + ...p, + url: buildProxyUrl(proxyBase, p.url, hParam, signSecret), + })), + }; +} + +/** + * Rewrite every URI in an HLS manifest so each segment, key, and sub-playlist + * is fetched through `/proxy`, carrying the same headers payload. + */ +function rewriteHlsManifest( + manifest: string, + manifestUrl: string, + proxyBase: string, + hParam: string | undefined, + signSecret: string | undefined, +): string { + const wrap = (uri: string) => { + try { + const abs = new URL(uri, manifestUrl).href; + return buildProxyUrl(proxyBase, abs, hParam, signSecret); + } catch { + return uri; + } + }; + return manifest + .split(/\r?\n/) + .map((line) => { + const t = line.trim(); + if (!t) return line; + if (t.startsWith('#')) + return t.replace(/URI=(["'])(.*?)\1/g, (_, q, u) => `URI=${q}${wrap(u)}${q}`); + return wrap(t); + }) + .join('\n'); +} + +/** Handle a single `/proxy` request. Returns `true` when it consumed the response. */ +export async function handleProxyRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + query: URLSearchParams, + options: ProxyOptions, +): Promise { + const targetUrl = query.get('url'); + if (!targetUrl) { + res.writeHead(400, { ...CORS, 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing param: url' })); + return; + } + + if (options.allowedHosts && options.allowedHosts.length > 0) { + let host: string; + try { + host = new URL(targetUrl).hostname; + } catch { + res.writeHead(400, { ...CORS, 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid url' })); + return; + } + const ok = options.allowedHosts.some((h) => host === h || host.endsWith(`.${h}`)); + if (!ok) { + res.writeHead(403, { ...CORS, 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `Target host ${host} not in allowlist` })); + return; + } + } + + const hParam = query.get('h'); + if (options.signSecret) { + const sig = query.get('sig'); + if (!sig) { + res.writeHead(401, { ...CORS, 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing required `sig` query parameter' })); + return; + } + const expected = computeSignature(targetUrl, hParam ?? undefined, options.signSecret); + if (!timingSafeEquals(sig, expected)) { + res.writeHead(401, { ...CORS, 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid proxy signature' })); + return; + } + } + + const upstreamHeaders: Record = { + Accept: '*/*', + 'User-Agent': UPSTREAM_UA, + 'Accept-Language': 'en-US,en;q=0.9', + 'Accept-Encoding': 'identity', + }; + if (hParam) { + try { + Object.assign( + upstreamHeaders, + JSON.parse(Buffer.from(hParam, 'base64').toString('utf8')) as Record, + ); + } catch { + // ignore malformed headers payload + } + } + if (req.headers.range) upstreamHeaders['Range'] = req.headers.range; + + const abortCtrl = new AbortController(); + req.on('close', () => abortCtrl.abort()); + + let upstream: Response; + try { + upstream = await fetch(targetUrl, { + headers: upstreamHeaders, + redirect: 'follow', + signal: abortCtrl.signal, + }); + } catch (fetchErr) { + const msg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr); + res.writeHead(502, { ...CORS, 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `Upstream fetch failed: ${msg}` })); + return; + } + + if (!upstream.ok) { + const text = await upstream.text().catch(() => ''); + res.writeHead(upstream.status === 404 ? 404 : 502, { + ...CORS, + 'Content-Type': 'application/json', + }); + res.end( + JSON.stringify({ error: `Upstream returned ${upstream.status}: ${text.slice(0, 100)}` }), + ); + return; + } + + const ct = upstream.headers.get('content-type') ?? ''; + const looksLikeHls = + ct.toLowerCase().includes('mpegurl') || targetUrl.split('?')[0].endsWith('.m3u8'); + + if (looksLikeHls) { + const text = await upstream.text(); + if (text.trim().startsWith('#EXTM3U') || looksLikeHls) { + const proxyBase = deriveProxyBase(req, options.base); + const rewritten = rewriteHlsManifest( + text, + targetUrl, + proxyBase, + hParam ?? undefined, + options.signSecret, + ); + const buf = Buffer.from(rewritten, 'utf8'); + res.writeHead(upstream.status, { + ...CORS, + 'Content-Type': 'application/vnd.apple.mpegurl', + 'Content-Length': buf.length, + }); + res.end(buf); + return; + } + } + + const ctOverride = query.get('ct'); + let contentType = ct || 'application/octet-stream'; + if (ctOverride) { + contentType = ctOverride; + } else if ( + targetUrl.split('?')[0].toLowerCase().endsWith('.ts') && + (ct.startsWith('image/') || (ct.startsWith('text/') && !ct.includes('html'))) + ) { + contentType = 'video/mp2t'; + } + if ( + contentType === 'application/octet-stream' && + targetUrl.split('?')[0].toLowerCase().endsWith('.mp4') + ) { + contentType = 'video/mp4'; + } + + const outHeaders: Record = { ...CORS, 'Content-Type': contentType }; + const cl = upstream.headers.get('content-length'); + if (cl) outHeaders['Content-Length'] = cl; + const cr = upstream.headers.get('content-range'); + if (cr) outHeaders['Content-Range'] = cr; + const ar = upstream.headers.get('accept-ranges'); + outHeaders['Accept-Ranges'] = ar ?? 'bytes'; + + res.writeHead(upstream.status, outHeaders); + + if (upstream.body) { + const readable = Readable.fromWeb(upstream.body as Parameters[0]); + readable.on('error', () => {}); + res.on('close', () => readable.destroy()); + readable.pipe(res); + } else { + res.end(); + } +} + +export { deriveProxyBase }; diff --git a/src/server/routes.ts b/src/server/routes.ts new file mode 100644 index 0000000..ed06dee --- /dev/null +++ b/src/server/routes.ts @@ -0,0 +1,459 @@ +import * as http from 'node:http'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import type { Sdk } from '../sdk.js'; +import type { Stream, Pages } from '../types.js'; +import { downloadVideo, downloadMangaChapter } from '../download/index.js'; +import { ProxyOptions, proxifyStream, proxifyPages, deriveProxyBase } from './proxy.js'; + +type Handler = ( + req: http.IncomingMessage, + res: http.ServerResponse, + params: Record, + query: URLSearchParams, +) => Promise; + +const CORS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': '*', + 'Access-Control-Expose-Headers': '*', +}; + +function json(res: http.ServerResponse, status: number, body: unknown): void { + const data = JSON.stringify(body); + res.writeHead(status, { + ...CORS, + '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; +} + +function openSse(res: http.ServerResponse): (data: unknown) => void { + res.writeHead(200, { + ...CORS, + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + return (data) => { + if (!res.writableEnded) res.write(`data: ${JSON.stringify(data)}\n\n`); + }; +} + +export function buildRoutes(sdk: Sdk, proxyOpts?: ProxyOptions): Array<[string, string, Handler]> { + // Token → completed download, cleaned up after 10 minutes or on serve. + const pendingDownloads = new Map< + string, + { filePath: string; tmpDir: string; filename: string } + >(); + + function storePending(filePath: string, tmpDir: string, filename: string): string { + const token = randomUUID(); + pendingDownloads.set(token, { filePath, tmpDir, filename }); + setTimeout( + () => { + const info = pendingDownloads.get(token); + if (info) { + try { + fs.unlinkSync(info.filePath); + } catch { + /* ignore */ + } + try { + fs.rmdirSync(info.tmpDir); + } catch { + /* ignore */ + } + pendingDownloads.delete(token); + } + }, + 10 * 60 * 1000, + ); + return token; + } + + function servePending(res: http.ServerResponse, token: string | null, contentType: string): void { + if (!token) return json(res, 400, { error: 'Missing param: token' }); + const info = pendingDownloads.get(token); + if (!info) return json(res, 404, { error: 'Download expired or not found' }); + pendingDownloads.delete(token); + let stat: fs.Stats; + try { + stat = fs.statSync(info.filePath); + } catch { + return json(res, 500, { error: 'File missing after download' }); + } + res.writeHead(200, { + ...CORS, + 'Content-Type': contentType, + 'Content-Length': stat.size, + 'Content-Disposition': `attachment; filename="${info.filename}"`, + }); + const rs = fs.createReadStream(info.filePath); + const cleanup = () => { + try { + fs.unlinkSync(info.filePath); + } catch { + /* ignore */ + } + try { + fs.rmdirSync(info.tmpDir); + } catch { + /* ignore */ + } + }; + rs.on('end', cleanup); + rs.on('error', cleanup); + rs.pipe(res); + } + + // Stream/Pages-bound proxy rewrite that derives the public base per request. + function maybeProxifyStream(req: http.IncomingMessage, stream: Stream): Stream { + if (!proxyOpts) return stream; + return proxifyStream(stream, deriveProxyBase(req, proxyOpts.base), proxyOpts.signSecret); + } + function maybeProxifyPages(req: http.IncomingMessage, pages: Pages): Pages { + if (!proxyOpts) return pages; + return proxifyPages(pages, deriveProxyBase(req, proxyOpts.base), proxyOpts.signSecret); + } + + 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/streams', + async (req, res, params) => { + const ac = abort(req); + const send = openSse(res); + try { + const result = sdk.stream(decodeURIComponent(params.id), { signal: ac.signal }); + for await (const stream of result) { + send(maybeProxifyStream(req, stream)); + } + } catch (e) { + send({ error: (e as Error).message }); + } + res.end(); + }, + ], + + [ + 'GET', + '/episode/:id/stream', + async (req, res, params, query) => { + const ac = abort(req); + const lang = (query.get('language') ?? 'sub') as 'sub' | 'dub' | 'raw'; + try { + const streams = await sdk.stream(decodeURIComponent(params.id), { signal: ac.signal }); + const pick = + streams.find((s) => s.language === lang) ?? + streams.find((s) => s.language === 'sub') ?? + streams[0]; + if (!pick) throw new Error('No streams available'); + json(res, 200, maybeProxifyStream(req, pick)); + } 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, maybeProxifyPages(req, 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 }); + } + }, + ], + + // ── Downloads ──────────────────────────────────────────────────────── + // Two-step flow: client opens an SSE connection on /progress to watch + // the download complete, then GETs /file with the returned token to + // pull the bytes. Avoids tying up a long-lived response with both + // progress events and the final blob. + + [ + 'GET', + '/download/video/progress', + async (req, res, _p, query) => { + const episodeId = query.get('episodeId'); + const language = (query.get('language') ?? 'sub') as 'sub' | 'dub' | 'raw'; + if (!episodeId) { + res.writeHead(400, { ...CORS, 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing param: episodeId' })); + return; + } + + const send = openSse(res); + const ac = abort(req); + try { + send({ type: 'progress', phase: 'resolving', detail: 'Resolving stream…' }); + const allStreams = await sdk.stream(decodeURIComponent(episodeId), { signal: ac.signal }); + const candidates = allStreams.filter((s) => s.language === language); + if (candidates.length === 0) + candidates.push(...allStreams.filter((s) => s.language === 'sub')); + if (candidates.length === 0) candidates.push(...allStreams); + if (candidates.length === 0) throw new Error('No streams available'); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'anime-sdk-dl-')); + const safeId = episodeId.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 32); + const filename = `${safeId}.mp4`; + const tmpFile = path.join(tmpDir, filename); + + let downloaded = false; + for (const stream of candidates) { + try { + await downloadVideo(stream, tmpFile, { + timeoutMs: 1_200_000, + onProgress: ({ phase, detail }) => send({ type: 'progress', phase, detail }), + }); + send({ type: 'complete', token: storePending(tmpFile, tmpDir, filename) }); + downloaded = true; + break; + } catch { + // try next candidate + } + } + if (!downloaded) { + try { + fs.unlinkSync(tmpFile); + } catch { + /* ignore */ + } + try { + fs.rmdirSync(tmpDir); + } catch { + /* ignore */ + } + send({ type: 'error', message: 'All stream candidates failed to download' }); + } + } catch (e) { + send({ type: 'error', message: e instanceof Error ? e.message : String(e) }); + } + res.end(); + }, + ], + + [ + 'GET', + '/download/video/file', + async (_req, res, _p, query) => { + servePending(res, query.get('token'), 'video/mp4'); + }, + ], + + [ + 'GET', + '/download/manga/chapter/progress', + async (req, res, _p, query) => { + const chapterId = query.get('chapterId'); + if (!chapterId) { + res.writeHead(400, { ...CORS, 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing param: chapterId' })); + return; + } + + const send = openSse(res); + const ac = abort(req); + try { + const pages = await sdk.pages(decodeURIComponent(chapterId), { signal: ac.signal }); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'anime-sdk-dl-')); + const safeId = chapterId.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 32); + const filename = `${safeId}.zip`; + const tmpFile = path.join(tmpDir, filename); + try { + send({ type: 'progress', downloaded: 0, total: pages.pages.length }); + await downloadMangaChapter(pages, tmpFile, { + onProgress: ({ downloaded, total }) => send({ type: 'progress', downloaded, total }), + }); + send({ type: 'complete', token: storePending(tmpFile, tmpDir, filename) }); + } catch (dlErr) { + try { + fs.unlinkSync(tmpFile); + } catch { + /* ignore */ + } + try { + fs.rmdirSync(tmpDir); + } catch { + /* ignore */ + } + send({ + type: 'error', + message: dlErr instanceof Error ? dlErr.message : String(dlErr), + }); + } + } catch (e) { + send({ type: 'error', message: e instanceof Error ? e.message : String(e) }); + } + res.end(); + }, + ], + + [ + 'GET', + '/download/manga/chapter/file', + async (_req, res, _p, query) => { + servePending(res, query.get('token'), 'application/zip'); + }, + ], + ]; +} + +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/src/sources/allmanga.ts b/src/sources/allmanga.ts new file mode 100644 index 0000000..e0db2b5 --- /dev/null +++ b/src/sources/allmanga.ts @@ -0,0 +1,345 @@ +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 } 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 payloadToStream(p: IVideoPayload, lang: 'sub' | 'dub' | 'raw', sourceId: string): Stream { + let server = sourceId; + try { + server = new URL(p.sourceUrl).hostname; + } catch {} + return { + url: p.sourceUrl, + source: sourceId, + server, + quality: p.quality, + language: lang, + isHls: p.isHLS, + headers: p.headers, + subtitles: (p.subtitles ?? []).map( + (s): Subtitle => ({ + url: s.url, + language: s.language, + label: s.label, + format: s.format ?? 'vtt', + }), + ), + }; +} + +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 }, + source: this.id, + mappings: {}, + }; + }); + } + + 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}` }), + number: num, + title: `Episode ${epStr}`, + languages: langs, + }); + } + items.sort((a, b) => a.number - b.number); + return { items }; + } + + async stream(episodeId: string, opts: SourceCallOpts): Promise { + const { r: rawUnit } = decodeId(episodeId); + const [showId, episodeString] = rawUnit.split('/'); + if (!showId || !episodeString) throw new Error(`Invalid AllManga episode id: ${rawUnit}`); + + const results = await Promise.allSettled( + (['sub', 'dub', 'raw'] as const).map(async (lang) => { + const sources = await this.fetchEpisodeSources(showId, episodeString, lang, opts.signal); + if (sources.length === 0) throw new Error(`no sources for ${lang}`); + sources.sort((a, b) => (Number(b.priority) || 0) - (Number(a.priority) || 0)); + const payloads: IVideoPayload[] = []; + for (const src of sources) { + try { + payloads.push(...(await this.extractSource(src, lang))); + } catch {} + } + if (payloads.length === 0) throw new Error(`no playable streams for ${lang}`); + return payloads.map((p) => payloadToStream(p, lang, this.id)); + }), + ); + + const streams = results.flatMap((r) => (r.status === 'fulfilled' ? r.value : [])); + if (streams.length === 0) throw new Error(`AllManga: no playable streams for ${rawUnit}`); + return streams; + } + + async lookupByMapping( + _mappings: Record, + _opts?: SourceCallOpts, + ): Promise { + // 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; + } + + 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..09d18d7 --- /dev/null +++ b/src/sources/anikoto.ts @@ -0,0 +1,124 @@ +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) {} + + 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, + source: this.id, + mappings: {}, + }; + }) + .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 }), + number: ep.number, + title: ep.title || `Episode ${ep.number}`, + languages: [ + ...(ep.embed_url?.sub ? ['sub' as const] : []), + ...(ep.embed_url?.dub ? ['dub' as const] : []), + ], + }), + ), + }; + } + + async stream(episodeId: string, opts: SourceCallOpts): Promise { + const { r: rawUnit } = decodeId(episodeId); + + const results = await Promise.allSettled( + (['sub', 'dub'] as const).map(async (lang) => { + 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('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('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, + source: this.id, + server: host || 'megaplay', + quality: 'auto' as const, + language: lang, + isHls: url.includes('.m3u8'), + headers: { Referer: 'https://megaplay.buzz/' }, + subtitles, + }; + }), + ); + + const streams = results.flatMap((r) => (r.status === 'fulfilled' ? [r.value] : [])); + if (streams.length === 0) throw new Error(`Anikoto: no playable streams for ${rawUnit}`); + return streams; + } +} diff --git a/src/sources/anilist.ts b/src/sources/anilist.ts new file mode 100644 index 0000000..5fc6c66 --- /dev/null +++ b/src/sources/anilist.ts @@ -0,0 +1,164 @@ +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, + source: sourceId, + 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/animeparadise.ts b/src/sources/animeparadise.ts new file mode 100644 index 0000000..d341115 --- /dev/null +++ b/src/sources/animeparadise.ts @@ -0,0 +1,101 @@ +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, + source: this.id, + mappings: {}, + }), + ); + } + + 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}` }), + number: parseFloat(ep.number), + title: ep.title ?? `Episode ${ep.number}`, + languages: ['sub'], + }), + ), + }; + } + + 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)}`; + 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, + source: this.id, + server: 'animeparadise', + quality: 'auto' as const, + language: 'sub' as const, + isHls: true, + headers: { Referer: 'https://animeparadise.moe/' }, + subtitles, + }, + ]; + } +} diff --git a/src/sources/base.ts b/src/sources/base.ts new file mode 100644 index 0000000..ecaca5b --- /dev/null +++ b/src/sources/base.ts @@ -0,0 +1,56 @@ +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): 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/src/sources/gogoanime.ts b/src/sources/gogoanime.ts new file mode 100644 index 0000000..b4edecc --- /dev/null +++ b/src/sources/gogoanime.ts @@ -0,0 +1,155 @@ +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; + } + + 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, + source: this.id, + mappings: {}, + }); + } + 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 }), + number, + title: displayTitle, + languages: [mediaId.toLowerCase().includes('-dub') ? 'dub' : 'sub'], + }); + } + 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 lang = rawUnit.toLowerCase().includes('-dub') ? 'dub' : 'sub'; + return payloads.map((p): Stream => { + let server = 'gogoanime'; + try { + server = new URL(p.sourceUrl).hostname; + } catch {} + return { + url: p.sourceUrl, + source: this.id, + server, + quality: p.quality, + language: lang, + isHls: p.isHLS, + headers: p.headers, + subtitles: [], + }; + }); + } +} diff --git a/src/sources/goyabu.ts b/src/sources/goyabu.ts new file mode 100644 index 0000000..fce6a9a --- /dev/null +++ b/src/sources/goyabu.ts @@ -0,0 +1,207 @@ +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; + 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, + source: this.id, + mappings: {}, + }); + } + 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 }), + number: num, + title: ep.episode_name ? `Episódio ${num}: ${ep.episode_name}` : `Episódio ${num}`, + languages: [mediaId.toLowerCase().includes('dublado') ? 'dub' : 'sub'], + }); + } + 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 }), + number: num, + title: `Episódio ${num}`, + languages: ['sub'], + }); + } + } + 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 lang = rawUnit.toLowerCase().includes('dublado') ? 'dub' : 'sub'; + return payloads.map((p): Stream => { + let server = 'goyabu'; + try { + server = new URL(p.sourceUrl).hostname; + } catch {} + return { + url: p.sourceUrl, + source: this.id, + server, + quality: p.quality, + language: lang, + isHls: p.isHLS, + headers: p.headers, + subtitles: [], + }; + }); + } + + 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..585bf09 --- /dev/null +++ b/src/sources/kitsu.ts @@ -0,0 +1,122 @@ +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 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, + source: sourceId, + 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(rawId: string, opts: SourceCallOpts): Promise { + // Sdk.info passes the decoded `r` field — ":" (set in + // mapNode) or, for legacy callers, a bare numeric id. + const sep = rawId.indexOf(':'); + let path: 'anime' | 'manga' = 'anime'; + let id = rawId; + if (sep >= 0 && (rawId.slice(0, sep) === 'anime' || rawId.slice(0, sep) === 'manga')) { + path = rawId.slice(0, sep) as 'anime' | 'manga'; + id = rawId.slice(sep + 1); + } + const url = `${this.apiUrl}/${path}/${id}?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 ${rawId}`); + 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..2447661 --- /dev/null +++ b/src/sources/mal.ts @@ -0,0 +1,135 @@ +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 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, + source: sourceId, + 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(rawId: string, opts: SourceCallOpts): Promise { + // Sdk.info passes the decoded `r` field. Raw is ":" + // (set in mapNode) or, for legacy callers, a bare numeric id. + const sep = rawId.indexOf(':'); + let path: 'anime' | 'manga' = 'anime'; + let numericId: number; + if (sep >= 0 && (rawId.slice(0, sep) === 'anime' || rawId.slice(0, sep) === 'manga')) { + path = rawId.slice(0, sep) as 'anime' | 'manga'; + numericId = Number(rawId.slice(sep + 1)); + } else { + numericId = Number(rawId); + } + 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 ${rawId}`); + 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..6a54a2e --- /dev/null +++ b/src/sources/mangadex.ts @@ -0,0 +1,88 @@ +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, + source: this.id, + mappings: {}, + }; + }); + } + + 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 }), + number: isNaN(num) ? 0 : num, + title: ch.attributes.title + ? `Ch. ${ch.attributes.chapter} - ${ch.attributes.title}` + : `Chapter ${ch.attributes.chapter}`, + }); + } + 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}`, + })); + return { pages }; + } + + async lookupByMapping( + _mappings: Record, + _opts?: SourceCallOpts, + ): Promise { + // MangaDex doesn't have a native MAL/AniList ID lookup endpoint. + // Cross-source resolution requires a title search via the search() method. + return null; + } +} diff --git a/src/sources/mangapill.ts b/src/sources/mangapill.ts new file mode 100644 index 0000000..5d2aa80 --- /dev/null +++ b/src/sources/mangapill.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://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, + source: this.id, + mappings: {}, + }); + } + } + 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 }), + number: num, + title, + }); + } + 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 } : null; + }) + .filter((p): p is NonNullable => p !== null); + return { pages }; + } + + 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..013bf67 --- /dev/null +++ b/src/sources/megaplay.ts @@ -0,0 +1,138 @@ +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; + } + + 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, + source: this.id, + mappings: { anilist: 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}` }), + number: i, + title: `Episode ${i}`, + languages: ['sub', 'dub'], + }); + } + return { items }; + } + + async stream(episodeId: string, opts: SourceCallOpts): Promise { + const { r: rawUnit } = decodeId(episodeId); + const [aniId, epNum] = rawUnit.split(':'); + + const results = await Promise.allSettled( + (['sub', 'dub'] as const).map(async (lang) => { + 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(`no mapping for AniList ID ${aniId} episode ${epNum} (${lang})`); + } + const fileIdMatch = embedPage.match(/File\s+(\d+)\s+-/); + if (!fileIdMatch) throw new Error('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('no video sources in response'); + + const url: string = srcJson.sources.file; + let server = 'megaplay'; + try { + server = 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, + source: this.id, + server, + quality: 'auto' as const, + language: lang, + isHls: url.includes('.m3u8'), + headers: { Referer: `${this.baseUrl}/` }, + subtitles, + }; + }), + ); + + const streams = results.flatMap((r) => (r.status === 'fulfilled' ? [r.value] : [])); + if (streams.length === 0) + throw new Error(`MegaPlay: no playable streams for AniList ${aniId} ep ${epNum}`); + return streams; + } + + 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..aca027a --- /dev/null +++ b/src/sources/weebcentral.ts @@ -0,0 +1,102 @@ +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, + source: this.id, + mappings: {}, + }); + } + } + } + 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] }), + number: num, + title, + }); + } + } + 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 } : null; + }) + .filter((p): p is NonNullable => p !== null); + return { pages }; + } + + async lookupByMapping( + _mappings: Record, + _opts?: SourceCallOpts, + ): Promise { + return null; + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..2cf804f --- /dev/null +++ b/src/types.ts @@ -0,0 +1,90 @@ +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; + source: string; + mappings: { + anilist?: number; + mal?: number; + kitsu?: number; + }; +} + +export interface Episode { + id: string; + number: number; + title?: string; + thumbnail?: string; + airDate?: string; + filler?: boolean; + recap?: boolean; + languages?: ('sub' | 'dub' | 'raw')[]; +} + +export interface Chapter { + id: string; + number: number; + title?: string; +} + +export interface Subtitle { + url: string; + language: string; + label: string; + format: 'vtt' | 'srt' | 'ass'; +} + +export interface Stream { + url: string; + source: string; + server: string; + quality: '1080p' | '720p' | '480p' | '360p' | 'auto'; + language: 'sub' | 'dub' | 'raw'; + isHls: boolean; + headers?: Record; + subtitles: Subtitle[]; +} + +export interface Pages { + pages: { url: string; width?: number; height?: 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/src/types/index.ts b/src/types/index.ts index 4a89969..1686ccb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,76 +1,10 @@ -export type MediaCatalogType = 'ANIME' | 'MOVIE' | 'TV' | 'MANGA'; - /** - * Unified Resource Name — every ID emitted by an SDK provider has shape - * `${providerId}:${rawId}`. The first colon is the separator; the raw part - * is opaque and may contain further colons or slashes. See `utils/urn.ts` - * for build/parse helpers. + * Internal value types shared by sources and extractors. Not exported from + * the public package surface — consumers use the value types in `src/types.ts`. */ -export type Urn = string; -/** - * Language/translation type for anime content. - * - 'sub': Subtitled (original Japanese audio with subtitles) - * - 'dub': Dubbed (localized audio track, typically English) - * - 'raw': No subtitles, original audio only - */ export type ContentLanguage = 'sub' | 'dub' | 'raw'; -export interface IMediaSearchResult { - id: string; - title: string; - thumbnailUrl?: string; - catalogType: MediaCatalogType; - providerId: string; - /** Languages available for this title (sub/dub/raw). Omitted if unknown. */ - availableLanguages?: ContentLanguage[]; - /** - * Year of release/publication, if the provider exposes it. The - * metadata-layer fuzzy matcher uses this as a discriminator: when two - * candidate titles are similar but their years differ by more than - * `MappingClientOptions.yearTolerance`, the lower-year match is - * rejected. Pre-existing providers that don't surface a year just - * disable the year filter for that candidate. - */ - year?: number; -} - -export interface IContentUnit { - id: string; // Provider-specific internal ID (language-agnostic when possible) - title: string; - number: number; - /** - * Translation types this unit can be played in. Providers return a single - * unified episode list — callers pick which translation to resolve at - * `resolveStream` time. Omitted if the provider cannot guarantee availability - * ahead of time. - */ - availableLanguages?: ContentLanguage[]; - /** - * Subtitle tracks known to be available for this unit, when the provider - * exposes that at episode-list time. Each entry carries the same shape as - * {@link ISubtitleTrack} *minus* the URL (URLs are only resolved during - * `resolveStream` / `fetchUnitTracks`). Omitted when the provider can't - * surface this without per-unit resolution. - */ - availableSubtitles?: ISubtitleAvailability[]; - /** - * Video qualities known to be available for this unit, when the provider - * exposes that at episode-list time. Omitted when not available. - */ - availableQualities?: IVideoPayload['quality'][]; - /** - * Per-episode metadata folded in by `BaseMetadataProvider.fetchContentUnits` - * from the metadata layer (AniList `streamingEpisodes`, Jikan filler flags, - * …). Optional — content providers themselves never populate these. - */ - thumbnailUrl?: string; - description?: string; - airDate?: string; - isFiller?: boolean; - isRecap?: boolean; -} - export interface ISubtitleAvailability { language: string; label: string; @@ -81,22 +15,10 @@ export interface ISubtitleTrack extends ISubtitleAvailability { url: string; } -/** - * Per-unit track metadata returned from `fetchUnitTracks`. Lets a consumer - * introspect which subtitle/video tracks exist for an episode *without* - * triggering a full stream resolution (which is often the slowest step). - */ -export interface IUnitTracks { - subtitles: ISubtitleTrack[]; - qualities: IVideoPayload['quality'][]; - headers?: Record; -} - export interface IVideoPayload { sourceUrl: string; isHLS: boolean; quality: '1080p' | '720p' | '480p' | '360p' | 'auto'; - /** The translation type of this stream (sub/dub/raw) */ language?: ContentLanguage; headers?: Record; subtitles?: ISubtitleTrack[]; @@ -107,10 +29,6 @@ export interface IMangaPayload { headers?: Record; } -export type ResolvedMediaStream = - | { type: 'video'; streams: IVideoPayload[] } - | { type: 'manga'; pages: IMangaPayload }; - export interface IDomElement { querySelector(selector: string): IDomElement | null; querySelectorAll(selector: string): IDomElement[]; @@ -123,266 +41,3 @@ export interface IDomElement { export interface IDomParser { parse(html: string): IDomElement; } - -/** - * Canonical per-call options bag. - * - * Every public method in the SDK (content providers, meta providers, - * mapping client) accepts an instance of this bag. Fields that aren't - * meaningful at a particular layer are simply ignored there — e.g. a - * content provider doesn't honour `strictEpisodeMatching`, but it's - * harmless when present. - * - * Threading a single shape through every layer means a caller can build - * one `CallOptions` (with an `AbortSignal` + their preferred meta-layer - * knobs) and pass it through unchanged. - */ -export interface CallOptions { - /** Cancels the in-flight call. Propagated to `fetch`, the rate limiter, - * and the retry loop. */ - signal?: AbortSignal; - /** - * Meta-layer only: when true, a missing episode number throws instead - * of falling back to closest-below. Content providers ignore this. - */ - strictEpisodeMatching?: boolean; - /** - * Meta-layer only: behaviour of the absolute-episode rescue when an - * exact episode-number match misses. - * - `'auto'` (default): triggers when the requested number is above the - * provider's max AND the provider has noticeably more episodes than - * the meta record says. - * - `'always'`: every miss tries the absolute lookup. - * - `'never'`: disables; falls through to closest-below. - */ - episodeAbsoluteMatching?: 'auto' | 'always' | 'never'; -} - -/** - * Minimal cache contract the SDK consumes — bring whatever store you want - * (in-memory Map, Redis, SQLite, edge KV). Both methods may be async; the - * SDK awaits them either way. - * - * Keys are stable, namespaced strings produced by the server layer - * (`search::`, `content::`, - * `stream:::`, `tracks:::`). - * Consumers can inspect the prefix to pick a TTL or refuse to cache - * particular endpoints (e.g. `/stream` when upstream URLs carry signed - * expiries). - * - * `get` returns `undefined` for a miss; any other value (including `null`) - * counts as a hit and is served as-is. - */ -export interface SdkCache { - get(key: string): unknown | Promise; - set(key: string, value: unknown): void | Promise; -} - -// ─── Metadata layer ───────────────────────────────────────────────────────── -// -// The metadata layer is a thin abstraction over external title catalogues -// (AniList, MAL/Jikan, Kitsu) that lets callers operate on a normalized -// `IMediaMetadata` record instead of a provider-specific shape, and then -// resolve playback through any content provider they choose. - -export type MediaStatus = - | 'FINISHED' - | 'RELEASING' - | 'NOT_YET_RELEASED' - | 'CANCELLED' - | 'HIATUS' - | 'UNKNOWN'; - -export type MediaFormat = - | 'TV' - | 'TV_SHORT' - | 'MOVIE' - | 'SPECIAL' - | 'OVA' - | 'ONA' - | 'MUSIC' - | 'MANGA' - | 'NOVEL' - | 'ONE_SHOT' - | 'UNKNOWN'; - -export type MediaSeason = 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL'; - -export interface IMediaTitle { - romaji?: string; - english?: string; - native?: string; - userPreferred?: string; -} - -export interface IMediaImage { - large?: string; - medium?: string; - small?: string; - /** Dominant colour in hex, when surfaced (e.g. AniList `coverImage.color`). */ - color?: string; -} - -/** - * Cross-source ID mappings. Lets a meta record carry the equivalent IDs in - * neighbouring catalogues (AniList ↔ MAL ↔ Kitsu) plus per-content-provider - * raw IDs once resolved — so two callers wanting the same title via two - * different content providers don't each re-pay the matching cost. - */ -export interface IMediaMappings { - anilist?: number; - mal?: number; - kitsu?: number; - thetvdb?: number; - tmdb?: number; - anidb?: number; - /** content provider id → raw media ID for this title on that provider */ - providers?: Record; -} - -export type MediaRelationType = - | 'SEQUEL' - | 'PREQUEL' - | 'PARENT' - | 'CHILD' - | 'SIDE_STORY' - | 'SPIN_OFF' - | 'ADAPTATION' - | 'ALTERNATIVE' - | 'CHARACTER' - | 'SUMMARY' - | 'COMPILATION' - | 'CONTAINS' - | 'OTHER'; - -export interface IMediaRelation { - /** URN of the related media in the meta provider's namespace. */ - id: Urn; - relationType: MediaRelationType; - catalogType: MediaCatalogType; - format?: MediaFormat; - status?: MediaStatus; - title: IMediaTitle; - cover?: IMediaImage; -} - -export interface IMediaCharacter { - id: Urn; - name: string; - /** "MAIN" / "SUPPORTING" / "BACKGROUND" (provider-defined). */ - role?: string; - image?: IMediaImage; - voiceActors?: Array<{ - id: Urn; - name: string; - language?: string; - image?: IMediaImage; - }>; -} - -export interface IMediaStaff { - id: Urn; - name: string; - role?: string; - image?: IMediaImage; -} - -export interface IMediaRecommendation { - id: Urn; - catalogType: MediaCatalogType; - format?: MediaFormat; - title: IMediaTitle; - cover?: IMediaImage; - /** Strength of the recommendation (provider-specific; AniList: vote count). */ - rating?: number; -} - -export interface IMediaExternalLink { - /** Site name, e.g. "Crunchyroll", "Netflix", "Official Site". */ - site: string; - url: string; - language?: string; - /** When site is a streaming service, that fact. */ - type?: 'STREAMING' | 'INFO' | 'SOCIAL'; -} - -/** - * Per-episode metadata sourced from the metadata layer (AniList's - * `streamingEpisodes`, Jikan's filler/recap flags). Folded onto - * `IContentUnit` records by `BaseMetadataProvider.fetchContentUnits`. - */ -export interface IStreamingEpisode { - number: number; - title?: string; - description?: string; - thumbnail?: string; - /** Where the catalogue saw this episode (e.g. Crunchyroll URL). */ - externalUrl?: string; - airDate?: string; - isFiller?: boolean; - isRecap?: boolean; -} - -export interface IMediaMetadata { - /** Unified URN — `${metaProviderId}:${nativeId}` (e.g. `anilist:21`). */ - id: Urn; - /** Meta-provider id that produced this record (e.g. `anilist`, `mal`). */ - providerId: string; - catalogType: MediaCatalogType; - title: IMediaTitle; - /** Synopsis. May contain HTML when the upstream catalogue ships it that way. */ - description?: string; - cover?: IMediaImage; - /** Landscape banner URL, when the catalogue ships one. */ - banner?: string; - status?: MediaStatus; - format?: MediaFormat; - /** Total episodes (for anime/TV) if known. */ - episodeCount?: number; - /** Total chapters (for manga) if known. */ - chapterCount?: number; - /** Per-episode duration in minutes, if known. */ - durationMinutes?: number; - genres?: string[]; - tags?: string[]; - studios?: string[]; - year?: number; - season?: MediaSeason; - /** ISO 8601 yyyy-mm-dd if known, otherwise omitted. */ - startDate?: string; - endDate?: string; - /** Normalized 0–100 score across all sources for easy comparison. */ - score?: number; - /** Trailer URL (typically YouTube embed) when known. */ - trailer?: string; - /** Adult-content flag (AniList `isAdult` and equivalents). */ - isAdult?: boolean; - synonyms?: string[]; - mappings?: IMediaMappings; - // ── Enrichments (optional; only set when the catalogue exposes them) ── - relations?: IMediaRelation[]; - characters?: IMediaCharacter[]; - staff?: IMediaStaff[]; - recommendations?: IMediaRecommendation[]; - externalLinks?: IMediaExternalLink[]; - streamingEpisodes?: IStreamingEpisode[]; -} - -/** - * A meta-provider-level search hit. The `id` is a URN in the meta provider's - * namespace (e.g. `anilist:21`); a full {@link IMediaMetadata} is one call - * to `fetchMediaInfo` away. - */ -export interface IMetaSearchResult { - id: Urn; - providerId: string; - catalogType: MediaCatalogType; - title: IMediaTitle; - cover?: IMediaImage; - year?: number; - format?: MediaFormat; - /** Light score so callers can rank without a second round-trip. */ - score?: number; - isAdult?: boolean; - mappings?: IMediaMappings; -} diff --git a/src/utils/subtitles.ts b/src/utils/subtitles.ts index 7db470d..a758fe9 100644 --- a/src/utils/subtitles.ts +++ b/src/utils/subtitles.ts @@ -1,4 +1,3 @@ -import * as crypto from 'node:crypto'; import { ISubtitleTrack } from '../types/index.js'; /** @@ -74,50 +73,3 @@ function inferFormatFromUrl(url: string): ISubtitleTrack['format'] | undefined { if (path.endsWith('.ass') || path.endsWith('.ssa')) return 'ass'; return undefined; } - -export interface ProxifySubtitleOptions { - /** Optional headers the proxy should attach when fetching upstream. */ - headers?: Record; - /** Override Content-Type on the proxy response (defaults to `text/vtt` for VTT). */ - contentType?: string; - /** - * When set, append an HMAC-SHA256 `sig` parameter computed over `url` - * (and `h=` payload, when present) keyed by this secret. Matches the - * scheme used by the server's `/proxy` endpoint when `proxySignSecret` - * is configured. - */ - signSecret?: string; -} - -/** - * Wrap a subtitle URL to flow through the SDK's `/proxy` endpoint. - * - * This is the same encoding `startServer({ proxy: true })` uses internally; - * exported so consumers that run their own HTTP layer (or call `resolveStream` - * directly in Node) can rewrite subtitle URLs the same way. - */ -export function proxifySubtitleUrl( - proxyBase: string, - track: ISubtitleTrack, - options: ProxifySubtitleOptions = {}, -): string { - const ct = - options.contentType ?? - (track.format === 'vtt' || (!track.format && /\.vtt(?:\?|$)/i.test(track.url)) - ? 'text/vtt' - : undefined); - const hParam = - options.headers && Object.keys(options.headers).length > 0 - ? Buffer.from(JSON.stringify(options.headers)).toString('base64') - : undefined; - const parts = [`url=${encodeURIComponent(track.url)}`]; - if (ct) parts.push(`ct=${encodeURIComponent(ct)}`); - if (hParam) parts.push(`h=${encodeURIComponent(hParam)}`); - if (options.signSecret) { - const h = crypto.createHmac('sha256', options.signSecret); - h.update(track.url); - if (hParam) h.update('|h=' + hParam); - parts.push(`sig=${h.digest('hex')}`); - } - return `${proxyBase}?${parts.join('&')}`; -} diff --git a/src/utils/urn.ts b/src/utils/urn.ts deleted file mode 100644 index c3e026c..0000000 --- a/src/utils/urn.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * 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/tests/allmanga-language.test.ts b/tests/allmanga-language.test.ts deleted file mode 100644 index 5de99b0..0000000 --- a/tests/allmanga-language.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Unit tests for AllmangaProvider language (sub/dub) support. - * Tests language propagation through the ID encoding scheme - * without making any network requests. - */ -import { describe, it, expect } from 'vitest'; -import { HttpClient } from '../src/transport/http.js'; -import { AllmangaProvider } from '../src/providers/AllmangaProvider.js'; - -describe('AllmangaProvider – language / sub/dub', () => { - const http = new HttpClient(); - - it('defaults to "sub" language when no option given', () => { - const provider = new AllmangaProvider(http); - // Access private defaultLanguage via a type cast for testing - expect((provider as any).defaultLanguage).toBe('sub'); - }); - - it('respects the defaultLanguage constructor option', () => { - const provider = new AllmangaProvider(http, { defaultLanguage: 'dub' }); - expect((provider as any).defaultLanguage).toBe('dub'); - }); - - it('ID encoding embeds the language as the third segment', () => { - // Simulate what fetchContentUnits produces - // ID format: {showId}/{episodeString}/{language} - const showId = 'FxkGk5c4TrD2'; - const epStr = '1'; - const lang = 'dub'; - const expectedId = `${showId}/${epStr}/${lang}`; - expect(expectedId).toBe('FxkGk5c4TrD2/1/dub'); - - // Verify resolveStream can parse it back - const parts = expectedId.split('/'); - expect(parts[0]).toBe(showId); - expect(parts[1]).toBe(epStr); - expect(parts[2]).toBe(lang); - }); - - it('resolveStream language param overrides unit-ID language', async () => { - // We can test the language resolution logic without network by trapping the - // GraphQL call. Instead, verify the unit-ID-based fallback logic directly. - const showId = 'testShow'; - const epStr = '5'; - const unitId = `${showId}/${epStr}/sub`; - - const parts = unitId.split('/'); - const unitLang = parts[2]; - // Override with 'dub' - const lang = ('dub' as any) ?? unitLang ?? 'sub'; - expect(lang).toBe('dub'); - }); -}); diff --git a/tests/dom.test.ts b/tests/dom.test.ts index a51219a..81a278a 100644 --- a/tests/dom.test.ts +++ b/tests/dom.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { DomRegistry, BrowserDomParser } from '../src/transport/dom.js'; +import { DomRegistry, BrowserDomParser } from '../src/internal/dom.js'; import { IDomParser, IDomElement } from '../src/types/index.js'; class MockElement implements IDomElement { @@ -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'); }); diff --git a/tests/download-server.test.ts b/tests/download-server.test.ts deleted file mode 100644 index 8896bbc..0000000 --- a/tests/download-server.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Unit tests for the download-related server routes. - * - * Uses a real (but ephemeral) HTTP server with mocked providers to verify - * the /download/* endpoints produce the correct response shapes without - * hitting the network. - * - * To run: npx vitest run tests/download-server.test.ts - */ -import { describe, it, expect, afterAll } from 'vitest'; -import * as http from 'node:http'; -import { BaseProvider } from '../src/providers/BaseProvider.js'; -import { HttpClient } from '../src/transport/http.js'; -import { - IMediaSearchResult, - IContentUnit, - ResolvedMediaStream, - MediaCatalogType, - ContentLanguage, -} from '../src/types/index.js'; -import { startServer } from '../src/server/index.js'; - -// ─── Mock provider helpers ────────────────────────────────────────────────── - -/** Tiny PNG: 1×1 pixel, valid PNG file (67 bytes). */ -const TINY_PNG = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==', - 'base64', -); - -/** Start a tiny HTTP server that serves the TINY_PNG on any GET. */ -function startImageServer(): Promise<{ server: http.Server; port: number }> { - return new Promise((resolve) => { - const s = http.createServer((req, res) => { - res.writeHead(200, { - 'Content-Type': 'image/png', - 'Content-Length': TINY_PNG.length, - }); - res.end(TINY_PNG); - }); - s.listen(0, () => { - const addr = s.address() as { port: number }; - resolve({ server: s, port: addr.port }); - }); - }); -} - -class MockMangaProvider extends BaseProvider { - readonly id = 'mock-manga'; - readonly supportedTypes: MediaCatalogType[] = ['MANGA']; - private imgPort: number; - - constructor(http: HttpClient, imgPort: number) { - super(http); - this.imgPort = imgPort; - } - - async search(): Promise { - return [ - { - id: 'manga-1', - title: 'Test Manga', - catalogType: 'MANGA', - providerId: this.id, - }, - ]; - } - - async fetchContentUnits(): Promise { - return [{ id: 'ch-1', title: 'Chapter 1', number: 1 }]; - } - - async resolveStream(): Promise { - return { - type: 'manga', - pages: { - imageUrls: [ - `http://localhost:${this.imgPort}/page1.png`, - `http://localhost:${this.imgPort}/page2.png`, - `http://localhost:${this.imgPort}/page3.png`, - ], - }, - }; - } -} - -// ─── Tests ─────────────────────────────────────────────────────────────────── - -describe('Server download routes', () => { - let sdkServer: http.Server; - let imgServer: http.Server; - let sdkPort: number; - - // Stand up both servers before any tests - const setup = (async () => { - const img = await startImageServer(); - imgServer = img.server; - - const httpClient = new HttpClient({ timeoutMs: 10000 }); - const manga = new MockMangaProvider(httpClient, img.port); - - sdkServer = startServer({ providers: [manga], port: 0 }); - - // Wait for the SDK server to be listening - await new Promise((resolve) => { - sdkServer.on('listening', () => { - sdkPort = (sdkServer.address() as { port: number }).port; - resolve(); - }); - }); - })(); - - afterAll(async () => { - await setup; // ensure setup is complete before teardown - sdkServer?.close(); - imgServer?.close(); - }); - - it('GET /download/manga/page returns an image with Content-Disposition', async () => { - await setup; - const url = `http://localhost:${sdkPort}/download/manga/page?provider=mock-manga&unitId=ch-1&page=0`; - const res = await fetch(url); - - expect(res.status).toBe(200); - expect(res.headers.get('content-type')).toBe('image/png'); - const disp = res.headers.get('content-disposition') ?? ''; - expect(disp).toContain('attachment'); - expect(disp).toContain('.png'); - - const buf = Buffer.from(await res.arrayBuffer()); - expect(buf.length).toBeGreaterThan(0); - // Verify PNG magic - expect(buf[0]).toBe(0x89); - expect(buf[1]).toBe(0x50); - }, 15000); - - it('GET /download/manga/chapter returns a ZIP', async () => { - await setup; - const url = `http://localhost:${sdkPort}/download/manga/chapter?provider=mock-manga&unitId=ch-1`; - const res = await fetch(url); - - expect(res.status).toBe(200); - expect(res.headers.get('content-type')).toBe('application/zip'); - const disp = res.headers.get('content-disposition') ?? ''; - expect(disp).toContain('attachment'); - expect(disp).toContain('.zip'); - - const buf = Buffer.from(await res.arrayBuffer()); - // ZIP magic: PK\x03\x04 - expect(buf[0]).toBe(0x50); - expect(buf[1]).toBe(0x4b); - expect(buf[2]).toBe(0x03); - expect(buf[3]).toBe(0x04); - }, 15000); - - it('GET /download/manga/page returns 400 for out-of-range page', async () => { - await setup; - const url = `http://localhost:${sdkPort}/download/manga/page?provider=mock-manga&unitId=ch-1&page=99`; - const res = await fetch(url); - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.error).toContain('out of range'); - }, 15000); - - it('GET /download/manga/page returns 400 for missing params', async () => { - await setup; - const res1 = await fetch(`http://localhost:${sdkPort}/download/manga/page?provider=mock-manga`); - expect(res1.status).toBe(400); - - const res2 = await fetch(`http://localhost:${sdkPort}/download/manga/page?unitId=ch-1`); - expect(res2.status).toBe(400); - }, 15000); - - it('GET /download/manga/chapter returns 400 for unknown provider', async () => { - await setup; - const url = `http://localhost:${sdkPort}/download/manga/chapter?provider=nonexistent&unitId=ch-1`; - const res = await fetch(url); - expect(res.status).toBe(400); - }, 15000); - - it('GET /download/video returns 400 for manga provider', async () => { - await setup; - const url = `http://localhost:${sdkPort}/download/video?provider=mock-manga&unitId=ch-1`; - const res = await fetch(url); - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.error).toContain('not video'); - }, 15000); -}); diff --git a/tests/e2e/allmanga.test.ts b/tests/e2e/allmanga.test.ts index 527265a..a8d2cb5 100644 --- a/tests/e2e/allmanga.test.ts +++ b/tests/e2e/allmanga.test.ts @@ -1,51 +1,46 @@ /** - * 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]; - - expect(target.providerId).toBe('allmanga'); - console.log(`AllManga selected: ${target.title} (${target.id})`); - - const units = await provider.fetchContentUnits(target.id, 'sub'); - expect(units.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); - 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); + r.title.preferred.toLowerCase().includes("beyond journey's end") && + !r.title.preferred.toLowerCase().includes('mini'), + ) ?? results[0]; + + const decoded = decodeId(target.id); + expect(decoded.s).toBe('allmanga'); + console.log(`AllManga selected: ${target.title.preferred} (${decoded.r})`); + + const mediaId = decoded.r; + const list = await source.episodes(mediaId, {}); + expect(list.items.length).toBeGreaterThan(0); + + const ep1 = list.items[0]; + const streams = await source.stream(ep1.id, {}); + expect(streams.length).toBeGreaterThan(0); + const stream = streams.find((s) => s.language === 'sub') ?? streams[0]; + expect(stream.url).toBeTruthy(); + expect(stream.isHls !== undefined).toBe(true); + expect(stream.source).toBe('allmanga'); + console.log(`AllManga stream: ${stream.url.slice(0, 80)} (${stream.language})`); + + 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..7e4fba4 100644 --- a/tests/e2e/anikoto.test.ts +++ b/tests/e2e/anikoto.test.ts @@ -1,71 +1,59 @@ /** - * 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 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 list = await source.episodes(decoded.r, {}); + expect(list.items.length).toBeGreaterThan(0); + const streams = await source.stream(list.items[0].id, {}); + expect(streams.length).toBeGreaterThan(0); + streams.forEach((s) => expect(s.source).toBe('anikoto')); + const sub = streams.find((s) => s.language === 'sub') ?? streams[0]; + expect(sub.url).toBeTruthy(); console.log( - `Anikoto (sub) resolved ${stream.streams.length} stream candidate(s); ` + - `top: ${stream.streams[0].sourceUrl.slice(0, 80)}`, + `Anikoto stream: ${sub.url.slice(0, 80)} (${streams.map((s) => s.language).join('+')})`, ); - const result = await captureStreamScreenshot('anikoto_sub', stream.streams); + const result = await captureStreamScreenshot('anikoto_sub', streamToPayload(sub)); expect(result.outputPath).toMatch(/screenshot_anikoto_sub\.png$/); }, 90000); - it('resolves a dub stream, and captures a screenshot', async () => { + it('resolves streams for a known episode and finds sub or dub', async () => { const http = new HttpClient({ timeoutMs: 25000 }); - const provider = new AnikotoProvider(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 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'); - return; + const source = new AnikotoSource(http); + + const list = await source.episodes('7457', {}); + expect(list.items.length).toBeGreaterThan(0); + + const streams = await source.stream(list.items[0].id, {}); + expect(streams.length).toBeGreaterThan(0); + + const dub = streams.find((s) => s.language === 'dub'); + if (dub) { + const result = await captureStreamScreenshot('anikoto_dub', streamToPayload(dub)); + expect(result.outputPath).toMatch(/screenshot_anikoto_dub\.png$/); + } else { + const sub = streams.find((s) => s.language === 'sub') ?? streams[0]; + const result = await captureStreamScreenshot('anikoto_sub2', streamToPayload(sub)); + expect(result.outputPath).toMatch(/screenshot_anikoto_sub2\.png$/); } - - 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 result = await captureStreamScreenshot('anikoto_dub', stream.streams); - expect(result.outputPath).toMatch(/screenshot_anikoto_dub\.png$/); }, 90000); }); diff --git a/tests/e2e/anilist.test.ts b/tests/e2e/anilist.test.ts new file mode 100644 index 0000000..3b891df --- /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.source).toBe('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/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..f23791e 100644 --- a/tests/e2e/animeparadise.test.ts +++ b/tests/e2e/animeparadise.test.ts @@ -1,39 +1,38 @@ /** - * 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 streams = await source.stream(list.items[0].id, {}); + expect(streams.length).toBeGreaterThan(0); + const stream = streams[0]; + expect(stream.url).toBeTruthy(); + expect(stream.source).toBe('animeparadise'); + console.log(`AnimeParadise stream: ${stream.url.slice(0, 80)} (${stream.language})`); - 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/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/download-e2e.test.ts b/tests/e2e/download-e2e.test.ts deleted file mode 100644 index 07bfcf5..0000000 --- a/tests/e2e/download-e2e.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -/** - * E2E download tests for every provider. - * - * Anime providers: downloads episode 1 of JJK (dub preferred, sub fallback) as .mp4 - * Manga providers: downloads a chapter of JJK as individual page + .zip - * - * Output lands in scratch/downloads/ (gitignored). - * - * To run: npx vitest run tests/e2e/download-e2e.test.ts - */ -import { describe, it, expect, beforeAll } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import { HttpClient } from '../../src/transport/http.js'; -import { AllmangaProvider } from '../../src/providers/AllmangaProvider.js'; -import { GogoanimeProvider } from '../../src/providers/GogoanimeProvider.js'; -import { GoyabuProvider } from '../../src/providers/GoyabuProvider.js'; -import { AnikotoProvider } from '../../src/providers/AnikotoProvider.js'; -import { MegaPlayProvider } from '../../src/providers/MegaPlayProvider.js'; -import { AnimeParadiseProvider } from '../../src/providers/AnimeParadiseProvider.js'; -import { MangadexProvider } from '../../src/providers/MangadexProvider.js'; -import { WeebcentralProvider } from '../../src/providers/WeebcentralProvider.js'; -import { MangapillProvider } from '../../src/providers/MangapillProvider.js'; -import { - downloadVideo, - downloadMangaPage, - downloadMangaChapter, -} from '../../src/download/download.js'; -import { ContentLanguage, ResolvedMediaStream } from '../../src/types/index.js'; -import { BaseProvider } from '../../src/providers/BaseProvider.js'; - -const DOWNLOAD_DIR = path.resolve(process.cwd(), 'scratch/downloads'); - -beforeAll(() => { - if (!fs.existsSync(DOWNLOAD_DIR)) fs.mkdirSync(DOWNLOAD_DIR, { recursive: true }); -}); - -// ─── Helper: resolve a video stream for an anime provider ──────────────────── - -async function resolveAnimeStream( - provider: BaseProvider, - query: string, - preferredLang: ContentLanguage = 'dub', -): Promise<{ stream: ResolvedMediaStream; episodeTitle: string }> { - const results = await provider.search(query); - expect(results.length).toBeGreaterThan(0); - - // Pick the first result that looks like JJK but is NOT a movie or season 2 - const target = - results.find((r) => { - const t = r.title.toLowerCase(); - const isJjk = t.includes('jujutsu') || t.includes('kaisen'); - const isS2OrMovie = - t.includes('movie') || - t.includes('0') || - t.includes('2') || - t.includes('3') || - t.includes('season 2') || - t.includes('2nd season') || - t.includes('hidden inventory') || - t.includes('shibuya') || - t.includes('culling game'); - return isJjk && !isS2OrMovie; - }) ?? results[0]; - console.log(`[${provider.id}] Selected: ${target.title} (${target.id})`); - - const units = await provider.fetchContentUnits(target.id); - expect(units.length).toBeGreaterThan(0); - - const ep1 = units[0]; - console.log(`[${provider.id}] Episode: ${ep1.title} (${ep1.id})`); - - // Try preferred language, fall back - let lang: ContentLanguage | undefined = preferredLang; - if (ep1.availableLanguages && !ep1.availableLanguages.includes(preferredLang)) { - lang = ep1.availableLanguages[0]; - } - - const stream = await provider.resolveStream(ep1.id, lang); - return { stream, episodeTitle: ep1.title }; -} - -// ─── Helper: resolve a manga stream for a manga provider ───────────────────── - -async function resolveMangaStream( - provider: BaseProvider, - query: string, -): Promise<{ stream: ResolvedMediaStream; chapterTitle: string }> { - const results = await provider.search(query); - expect(results.length).toBeGreaterThan(0); - - const target = - results.find( - (r) => r.title.toLowerCase().includes('jujutsu') || r.title.toLowerCase().includes('kaisen'), - ) ?? results[0]; - console.log(`[${provider.id}] Selected: ${target.title} (${target.id})`); - - const units = await provider.fetchContentUnits(target.id); - expect(units.length).toBeGreaterThan(0); - - const ch1 = units[0]; - console.log(`[${provider.id}] Chapter: ${ch1.title} (${ch1.id})`); - - const stream = await provider.resolveStream(ch1.id); - return { stream, chapterTitle: ch1.title }; -} - -// ─── Helper: verify MP4 file ───────────────────────────────────────────────── - -function assertValidMp4(filePath: string): void { - expect(fs.existsSync(filePath)).toBe(true); - const stat = fs.statSync(filePath); - // We expect at least a 20MB file. High quality will be > 200MB, but some providers serve 480p/720p. - expect(stat.size).toBeGreaterThan(20 * 1024 * 1024); - console.log(` → ${path.basename(filePath)}: ${(stat.size / 1024 / 1024).toFixed(2)} MB`); - - // Check for ftyp box (MP4 container signature) at offset 4 - const buf = Buffer.alloc(12); - const fd = fs.openSync(filePath, 'r'); - fs.readSync(fd, buf, 0, 12, 0); - fs.closeSync(fd); - const ftyp = buf.subarray(4, 8).toString('ascii'); - expect(ftyp).toBe('ftyp'); -} - -// ─── Helper: verify image file ─────────────────────────────────────────────── - -function assertValidImage(filePath: string): void { - expect(fs.existsSync(filePath)).toBe(true); - const stat = fs.statSync(filePath); - expect(stat.size).toBeGreaterThan(1024); - console.log(` → ${path.basename(filePath)}: ${(stat.size / 1024).toFixed(1)} KB`); -} - -// ─── Helper: verify ZIP file ───────────────────────────────────────────────── - -function assertValidZip(filePath: string): void { - expect(fs.existsSync(filePath)).toBe(true); - const stat = fs.statSync(filePath); - expect(stat.size).toBeGreaterThan(1024); - console.log(` → ${path.basename(filePath)}: ${(stat.size / 1024).toFixed(1)} KB`); - - const buf = Buffer.alloc(4); - const fd = fs.openSync(filePath, 'r'); - fs.readSync(fd, buf, 0, 4, 0); - fs.closeSync(fd); - // PK\x03\x04 - expect(buf[0]).toBe(0x50); - expect(buf[1]).toBe(0x4b); - expect(buf[2]).toBe(0x03); - expect(buf[3]).toBe(0x04); -} - -// ─── Anime Provider Download Tests ────────────────────────────────────────── - -describe('Anime Downloads (JJK Episode 1)', () => { - const http = new HttpClient({ timeoutMs: 30000 }); - - it('allmanga → .mp4', async () => { - const provider = new AllmangaProvider(http); - const { stream } = await resolveAnimeStream(provider, 'Jujutsu Kaisen'); - expect(stream.type).toBe('video'); - if (stream.type !== 'video') return; - - const outPath = path.join(DOWNLOAD_DIR, 'allmanga_jjk_ep1.mp4'); - await downloadVideo(stream.streams, outPath, { timeoutMs: 1_200_000 }); - assertValidMp4(outPath); - }, 1_200_000); - - it('gogoanime → .mp4', async () => { - const provider = new GogoanimeProvider(http); - const { stream } = await resolveAnimeStream(provider, 'Jujutsu Kaisen'); - expect(stream.type).toBe('video'); - if (stream.type !== 'video') return; - - const outPath = path.join(DOWNLOAD_DIR, 'gogoanime_jjk_ep1.mp4'); - await downloadVideo(stream.streams, outPath, { timeoutMs: 1_200_000 }); - assertValidMp4(outPath); - }, 1_200_000); - - it('goyabu → .mp4', async () => { - const provider = new GoyabuProvider(http); - const { stream } = await resolveAnimeStream(provider, 'Jujutsu Kaisen'); - expect(stream.type).toBe('video'); - if (stream.type !== 'video') return; - - const outPath = path.join(DOWNLOAD_DIR, 'goyabu_jjk_ep1.mp4'); - await downloadVideo(stream.streams, outPath, { timeoutMs: 1_200_000 }); - assertValidMp4(outPath); - }, 1_200_000); - - it('anikoto → .mp4', async () => { - const provider = new AnikotoProvider(http); - const { stream } = await resolveAnimeStream(provider, 'Jujutsu Kaisen'); - expect(stream.type).toBe('video'); - if (stream.type !== 'video') return; - - const outPath = path.join(DOWNLOAD_DIR, 'anikoto_jjk_ep1.mp4'); - await downloadVideo(stream.streams, outPath, { timeoutMs: 1_200_000 }); - assertValidMp4(outPath); - }, 1_200_000); - - it('megaplay → .mp4', async () => { - const provider = new MegaPlayProvider(http); - const { stream } = await resolveAnimeStream(provider, 'Jujutsu Kaisen'); - expect(stream.type).toBe('video'); - if (stream.type !== 'video') return; - - const outPath = path.join(DOWNLOAD_DIR, 'megaplay_jjk_ep1.mp4'); - await downloadVideo(stream.streams, outPath, { timeoutMs: 1_200_000 }); - assertValidMp4(outPath); - }, 1_200_000); - - it('animeparadise → .mp4', async () => { - const provider = new AnimeParadiseProvider(http); - const { stream } = await resolveAnimeStream(provider, 'Jujutsu Kaisen', 'sub'); - expect(stream.type).toBe('video'); - if (stream.type !== 'video') return; - - const outPath = path.join(DOWNLOAD_DIR, 'animeparadise_jjk_ep1.mp4'); - await downloadVideo(stream.streams, outPath, { timeoutMs: 1_200_000 }); - assertValidMp4(outPath); - }, 1_200_000); -}); - -// ─── Manga Provider Download Tests ────────────────────────────────────────── - -describe('Manga Downloads (JJK Chapter)', () => { - const http = new HttpClient({ timeoutMs: 30000 }); - - it('mangadex → page image + chapter .zip', async () => { - const provider = new MangadexProvider(http); - const { stream } = await resolveMangaStream(provider, 'Jujutsu Kaisen'); - expect(stream.type).toBe('manga'); - if (stream.type !== 'manga') return; - - // Download single page - const pageResult = await downloadMangaPage(stream.pages, 0, DOWNLOAD_DIR); - assertValidImage(pageResult.outputPath); - - // Rename to standard name - const pageOutPath = path.join( - DOWNLOAD_DIR, - `mangadex_jjk_page1${path.extname(pageResult.outputPath)}`, - ); - if (pageResult.outputPath !== pageOutPath) { - fs.renameSync(pageResult.outputPath, pageOutPath); - } - - // Download chapter as ZIP - const zipPath = path.join(DOWNLOAD_DIR, 'mangadex_jjk_chapter.zip'); - await downloadMangaChapter(stream.pages, zipPath); - assertValidZip(zipPath); - }, 120_000); - - it('weebcentral → page image + chapter .zip', async () => { - const provider = new WeebcentralProvider(http); - const { stream } = await resolveMangaStream(provider, 'Jujutsu Kaisen'); - expect(stream.type).toBe('manga'); - if (stream.type !== 'manga') return; - - // Download single page - const pageResult = await downloadMangaPage(stream.pages, 0, DOWNLOAD_DIR); - assertValidImage(pageResult.outputPath); - - const pageOutPath = path.join( - DOWNLOAD_DIR, - `weebcentral_jjk_page1${path.extname(pageResult.outputPath)}`, - ); - if (pageResult.outputPath !== pageOutPath) { - fs.renameSync(pageResult.outputPath, pageOutPath); - } - - // Download chapter as ZIP - const zipPath = path.join(DOWNLOAD_DIR, 'weebcentral_jjk_chapter.zip'); - await downloadMangaChapter(stream.pages, zipPath); - assertValidZip(zipPath); - }, 120_000); - - it('mangapill → page image + chapter .zip', async () => { - const provider = new MangapillProvider(http); - const { stream } = await resolveMangaStream(provider, 'Jujutsu Kaisen'); - expect(stream.type).toBe('manga'); - if (stream.type !== 'manga') return; - - // Download single page - const pageResult = await downloadMangaPage(stream.pages, 0, DOWNLOAD_DIR); - assertValidImage(pageResult.outputPath); - - const pageOutPath = path.join( - DOWNLOAD_DIR, - `mangapill_jjk_page1${path.extname(pageResult.outputPath)}`, - ); - if (pageResult.outputPath !== pageOutPath) { - fs.renameSync(pageResult.outputPath, pageOutPath); - } - - // Download chapter as ZIP - const zipPath = path.join(DOWNLOAD_DIR, 'mangapill_jjk_chapter.zip'); - await downloadMangaChapter(stream.pages, zipPath); - assertValidZip(zipPath); - }, 120_000); -}); diff --git a/tests/e2e/gogoanime.test.ts b/tests/e2e/gogoanime.test.ts index 0dff7e0..c60a7e9 100644 --- a/tests/e2e/gogoanime.test.ts +++ b/tests/e2e/gogoanime.test.ts @@ -1,41 +1,36 @@ /** - * 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 streams = await source.stream(list.items[0].id, {}); + expect(streams.length).toBeGreaterThan(0); + const stream = streams[0]; + expect(stream.url).toBeTruthy(); + expect(stream.source).toBe('gogoanime'); + console.log(`GogoAnime stream: ${stream.url.slice(0, 80)} (${stream.language})`); - 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..fe2f646 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,25 @@ 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 streams = await source.stream(list.items[0].id, {}); + expect(streams.length).toBeGreaterThan(0); + const stream = streams[0]; + expect(stream.url).toBeTruthy(); + expect(stream.source).toBe('goyabu'); + console.log(`Goyabu stream: ${stream.url.slice(0, 80)} (${stream.language})`); - 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/httpClient.test.ts b/tests/e2e/httpClient.test.ts index ec32f6e..92012a0 100644 --- a/tests/e2e/httpClient.test.ts +++ b/tests/e2e/httpClient.test.ts @@ -8,8 +8,8 @@ */ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import * as http from 'node:http'; -import { HttpClient } from '../../src/transport/http.js'; -import { RateLimiter } from '../../src/transport/rateLimiter.js'; +import { HttpClient } from '../../src/internal/http.js'; +import { RateLimiter } from '../../src/internal/rateLimiter.js'; // In-process test upstream: each test installs a fresh request handler // via setHandler(); the server records request count + headers so the diff --git a/tests/e2e/kitsuMeta.test.ts b/tests/e2e/kitsuMeta.test.ts index 042907e..9f77ed0 100644 --- a/tests/e2e/kitsuMeta.test.ts +++ b/tests/e2e/kitsuMeta.test.ts @@ -1,52 +1,36 @@ /** - * 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); + // Sdk.info passes the decoded `r` field; mirror that contract. + const info = await source.info('anime:1', {}); + expect(info.kind).toBe('anime'); + expect(info.episodeCount).toBe(26); + expect(info.mappings.kitsu).toBe(1); + 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..fb7dc0b 100644 --- a/tests/e2e/malMeta.test.ts +++ b/tests/e2e/malMeta.test.ts @@ -1,90 +1,44 @@ /** - * 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); + // Sdk.info passes the decoded `r` field. Mirror that contract here. + const info = await source.info('anime:1', {}); + 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..264c0fa 100644 --- a/tests/e2e/megaplay.test.ts +++ b/tests/e2e/megaplay.test.ts @@ -1,44 +1,44 @@ 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); + it('should resolve and capture sub+dub streams for Frieren episode 1', async () => { + const ep1Id = (await source.episodes('154587', {})).items[0].id; + const streams = await source.stream(ep1Id, {}); + expect(streams.length).toBeGreaterThan(0); + streams.forEach((s) => expect(s.source).toBe('megaplay')); + const sub = streams.find((s) => s.language === 'sub'); + if (sub) { + const result = await captureStreamScreenshot('megaplay_sub', streamToPayload(sub)); 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); + const dub = streams.find((s) => s.language === 'dub'); + if (dub) { + const result = await captureStreamScreenshot('megaplay_dub', streamToPayload(dub)); expect(fs.existsSync(result.outputPath)).toBe(true); expect(fs.statSync(result.outputPath).size).toBeGreaterThan(1024); } - }, 30000); + }, 60000); }); 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(); - }); -}); 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/proxyAllowlist.test.ts b/tests/e2e/proxyAllowlist.test.ts index 0fa9133..a1133c5 100644 --- a/tests/e2e/proxyAllowlist.test.ts +++ b/tests/e2e/proxyAllowlist.test.ts @@ -1,28 +1,37 @@ /** - * Live test of the `/proxy` SSRF allowlist. Spawns a real server with - * `proxyAllowedHosts: ['example.com']` and verifies: - * - allowed hosts are proxied successfully, - * - any other host is rejected with 403. + * Verifies the proxy SSRF allowlist. + * + * A target hostname not covered by `allowedHosts` must return 403; a hostname + * that suffix-matches an entry must succeed. */ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import * as http from 'node:http'; -import { HttpClient } from '../../src/transport/http.js'; -import { AllmangaProvider } from '../../src/providers/AllmangaProvider.js'; import { startServer } 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 p = (s.address() as http.AddressInfo).port; + s.close(() => resolve(p)); + }); + }); +} + beforeAll(async () => { - const httpClient = new HttpClient({ timeoutMs: 20_000 }); + const port = await getFreePort(); server = startServer({ - providers: [new AllmangaProvider(httpClient)], - proxy: true, - proxyAllowedHosts: ['example.com'], - port: await freePort(), + port, + sdk: createSdk({ sources: ['anilist'] }), + // Suffix-matched: covers s4.anilist.co (and every other *.anilist.co). + proxy: { allowedHosts: ['anilist.co'] }, }); - const addr = server.address(); - if (!addr || typeof addr === 'string') throw new Error('no address'); + await new Promise((r) => server.on('listening', r)); + const addr = server.address() as http.AddressInfo; baseUrl = `http://127.0.0.1:${addr.port}`; }); @@ -30,36 +39,22 @@ afterAll(async () => { await new Promise((resolve) => server.close(() => resolve())); }); -describe('/proxy SSRF allowlist', () => { - it('allows example.com and any of its subdomains', async () => { - const r = await fetch(`${baseUrl}/proxy?url=${encodeURIComponent('https://example.com/')}`); - expect(r.status).toBe(200); - }, 30_000); +describe('proxy SSRF allowlist', () => { + it('allows a target whose hostname suffix-matches the allowlist', async () => { + const target = 'https://s4.anilist.co/file/anilistcdn/character/large/default.jpg'; + const res = await fetch(`${baseUrl}/proxy?url=${encodeURIComponent(target)}`); + expect(res.status).toBe(200); + await res.arrayBuffer(); + }, 20000); - it('rejects requests outside the allowlist with 403', async () => { - const r = await fetch(`${baseUrl}/proxy?url=${encodeURIComponent('https://wikipedia.org/')}`); - expect(r.status).toBe(403); - const body = (await r.json()) as { error: string }; - expect(body.error).toMatch(/allowlist/); + it('rejects a target whose hostname is outside the allowlist', async () => { + const target = 'https://example.com/'; + const res = await fetch(`${baseUrl}/proxy?url=${encodeURIComponent(target)}`); + expect(res.status).toBe(403); }); - it('rejects 400 on a malformed url', async () => { - const r = await fetch(`${baseUrl}/proxy?url=${encodeURIComponent('not a url')}`); - expect(r.status).toBe(400); + it('rejects a malformed url', async () => { + const res = await fetch(`${baseUrl}/proxy?url=not-a-url`); + expect(res.status).toBe(400); }); }); - -async function freePort(): Promise { - return new Promise((resolve, reject) => { - const s = http.createServer(); - s.listen(0, () => { - const addr = s.address(); - if (!addr || typeof addr === 'string') { - reject(new Error('no address')); - return; - } - const port = addr.port; - s.close(() => resolve(port)); - }); - }); -} diff --git a/tests/e2e/proxySigning.test.ts b/tests/e2e/proxySigning.test.ts index 80eeed4..b8e6ea2 100644 --- a/tests/e2e/proxySigning.test.ts +++ b/tests/e2e/proxySigning.test.ts @@ -1,41 +1,44 @@ /** * Live test of the `/proxy` HMAC-signing scheme. * - * Spawns a real server with `proxySignSecret`, asks for a real stream via - * the meta layer (so the response goes through the server's `proxyifyStream` - * rewriter, which signs every URL), then verifies: + * Spawns a real server with proxy.signSecret and verifies: + * - A valid `sig` proxies the upstream byte stream. + * - An invalid `sig` returns 401. + * - A missing `sig` returns 401. * - * - The rewritten `sourceUrl` contains a `sig` query param. - * - Hitting `/proxy` with a *valid* `sig` proxies the upstream byte stream. - * - Hitting `/proxy` with an invalid signature returns 401. - * - Hitting `/proxy` with no signature returns 401. - * - * To keep the test deterministic we sign a benign upstream URL (AniList's - * public OpenAPI cover image — small, fast, always reachable). The flow - * doesn't touch any provider; the proxy signing logic is fully exercised. + * The upstream target is a small static asset (AniList CDN). Hitting it is + * cheap and never touches a real stream provider. */ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import * as http from 'node:http'; import * as crypto from 'node:crypto'; -import { HttpClient } from '../../src/transport/http.js'; -import { AllmangaProvider } from '../../src/providers/AllmangaProvider.js'; import { startServer } from '../../src/server/index.js'; +import { createSdk } from '../../src/sdk.js'; let server: http.Server; let baseUrl: string; const SECRET = 'super-secret-key'; +const TARGET = 'https://s4.anilist.co/file/anilistcdn/character/large/default.jpg'; + +async function getFreePort(): Promise { + return new Promise((resolve) => { + const s = http.createServer(); + s.listen(0, () => { + const p = (s.address() as http.AddressInfo).port; + s.close(() => resolve(p)); + }); + }); +} beforeAll(async () => { - const httpClient = new HttpClient({ timeoutMs: 20_000 }); + const port = await getFreePort(); server = startServer({ - providers: [new AllmangaProvider(httpClient)], - metaProviders: [], - proxy: true, - proxySignSecret: SECRET, - port: await getFreePort(), + port, + sdk: createSdk({ sources: ['anilist'] }), + proxy: { signSecret: SECRET }, }); - const addr = server.address(); - if (!addr || typeof addr === 'string') throw new Error('no address'); + await new Promise((r) => server.on('listening', r)); + const addr = server.address() as http.AddressInfo; baseUrl = `http://127.0.0.1:${addr.port}`; }); @@ -50,61 +53,22 @@ function sign(url: string, h?: string): string { return hmac.digest('hex'); } -describe('/proxy signature enforcement', () => { - // example.com is the IANA-reserved demonstration domain — always - // reachable and serves a small static HTML doc on GET. - const target = 'https://example.com/'; +describe('proxy HMAC signing', () => { + it('accepts a request with a valid signature', async () => { + const sig = sign(TARGET); + const res = await fetch(`${baseUrl}/proxy?url=${encodeURIComponent(TARGET)}&sig=${sig}`); + expect(res.status).toBe(200); + // Drain so we don't leak a half-open response. + await res.arrayBuffer(); + }, 20000); - it('rejects unsigned requests with 401', async () => { - const res = await fetch(`${baseUrl}/proxy?url=${encodeURIComponent(target)}`); + it('rejects a request with an invalid signature', async () => { + const res = await fetch(`${baseUrl}/proxy?url=${encodeURIComponent(TARGET)}&sig=deadbeef`); expect(res.status).toBe(401); - const body = (await res.json()) as { error: string }; - expect(body.error).toMatch(/sig/i); }); - it('rejects bad signatures with 401', async () => { - const res = await fetch(`${baseUrl}/proxy?url=${encodeURIComponent(target)}&sig=deadbeef`); + it('rejects a request with no signature', async () => { + const res = await fetch(`${baseUrl}/proxy?url=${encodeURIComponent(TARGET)}`); expect(res.status).toBe(401); }); - - it('accepts a valid signature and streams the upstream body', async () => { - const sig = sign(target); - const res = await fetch(`${baseUrl}/proxy?url=${encodeURIComponent(target)}&sig=${sig}`); - expect(res.status).toBe(200); - const text = await res.text(); - expect(text.length).toBeGreaterThan(0); - }, 30_000); - - it('signature covers the headers (`h`) parameter too', async () => { - const headers = { 'X-Test': 'yes' }; - const h = Buffer.from(JSON.stringify(headers)).toString('base64'); - const goodSig = sign(target, h); - const r1 = await fetch( - `${baseUrl}/proxy?url=${encodeURIComponent(target)}&h=${encodeURIComponent(h)}&sig=${goodSig}`, - ); - expect(r1.status).toBe(200); - - // Re-using the URL-only signature must be rejected — the `h` payload - // changes what's being proxied, so the sig must reflect it. - const urlOnlySig = sign(target); - const r2 = await fetch( - `${baseUrl}/proxy?url=${encodeURIComponent(target)}&h=${encodeURIComponent(h)}&sig=${urlOnlySig}`, - ); - expect(r2.status).toBe(401); - }, 30_000); }); - -async function getFreePort(): Promise { - return new Promise((resolve, reject) => { - const s = http.createServer(); - s.listen(0, () => { - const addr = s.address(); - if (!addr || typeof addr === 'string') { - reject(new Error('no address')); - return; - } - const port = addr.port; - s.close(() => resolve(port)); - }); - }); -} diff --git a/tests/e2e/screenshotHelper.ts b/tests/e2e/screenshotHelper.ts index a73051a..c37e9e1 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.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/sdkInfo.test.ts b/tests/e2e/sdkInfo.test.ts new file mode 100644 index 0000000..e31e34f --- /dev/null +++ b/tests/e2e/sdkInfo.test.ts @@ -0,0 +1,62 @@ +/** + * SDK-level live tests for the search → info → sources path. + * + * Catches the class of bug where Sdk.info passes the raw `r` field to a + * source whose info() implementation tries to base64-decode it again — + * which is what crashed /media/:id when clicking a MAL or Kitsu search + * result with id "anime:". + */ +import { describe, expect, it } from 'vitest'; +import { createSdk } from '../../src/sdk.js'; + +describe('Sdk.info(string) round-trips opaque search ids', () => { + it('MAL: search → info', async () => { + const sdk = createSdk({ sources: ['mal'], http: { timeoutMs: 25_000 } }); + const results = await sdk.search('Cowboy Bebop', { kind: 'anime' }); + expect(results.length).toBeGreaterThan(0); + const hit = results.find((r) => r.mappings.mal === 1); + expect(hit).toBeDefined(); + + const info = await sdk.info(hit!.id); + expect(info.kind).toBe('anime'); + expect(info.mappings.mal).toBe(1); + expect(info.episodeCount).toBe(26); + }, 60_000); + + it('Kitsu: search → info', async () => { + const sdk = createSdk({ sources: ['kitsu'], http: { timeoutMs: 25_000 } }); + const results = await sdk.search('Cowboy Bebop', { kind: 'anime' }); + expect(results.length).toBeGreaterThan(0); + const hit = results.find((r) => r.mappings.kitsu === 1); + expect(hit).toBeDefined(); + + const info = await sdk.info(hit!.id); + expect(info.kind).toBe('anime'); + expect(info.mappings.kitsu).toBe(1); + }, 60_000); + + it('AniList: search → info', async () => { + const sdk = createSdk({ sources: ['anilist'], http: { timeoutMs: 25_000 } }); + const results = await sdk.search('Frieren', { kind: 'anime' }); + expect(results.length).toBeGreaterThan(0); + const hit = results[0]; + + const info = await sdk.info(hit.id); + expect(info.kind).toBe('anime'); + expect(info.title.preferred).toBeTruthy(); + }, 60_000); +}); + +describe('Sdk.sources(string) round-trips opaque search ids', () => { + it('does not crash when fed a MAL search result id', async () => { + const sdk = createSdk({ sources: ['mal', 'megaplay'], http: { timeoutMs: 25_000 } }); + const results = await sdk.search('Cowboy Bebop', { kind: 'anime' }); + const hit = results.find((r) => r.mappings.mal === 1); + expect(hit).toBeDefined(); + + const sources = await sdk.sources(hit!.id); + expect(Array.isArray(sources)).toBe(true); + // megaplay should be a candidate since it can resolve via MAL/AniList + expect(sources.length).toBeGreaterThan(0); + }, 60_000); +}); diff --git a/tests/e2e/sdkSources.test.ts b/tests/e2e/sdkSources.test.ts new file mode 100644 index 0000000..a40de01 --- /dev/null +++ b/tests/e2e/sdkSources.test.ts @@ -0,0 +1,55 @@ +/** + * Live E2E for the full search → sources path with the title-search fallback + * enabled. Verifies that playback sources without a native cross-source + * mapping (allmanga, animeparadise, anikoto, gogoanime) become reachable + * from a catalogue search result. + */ +import { describe, expect, it } from 'vitest'; +import { createSdk } from '../../src/sdk.js'; + +describe('Sdk.sources — title-search fallback makes non-mapping sources available', () => { + it('AllManga resolves a popular AniList result via title search', async () => { + const sdk = createSdk({ + sources: ['anilist', 'allmanga'], + http: { timeoutMs: 25_000 }, + }); + const results = await sdk.search('Frieren', { kind: 'anime' }); + const hit = results.find((r) => r.mappings.anilist === 154587); + expect(hit).toBeDefined(); + + const sources = await sdk.sources(hit!); + const allmanga = sources.find((s) => s.id === 'allmanga'); + expect(allmanga).toBeDefined(); + expect(allmanga!.status).toBe('available'); + }, 90_000); + + it('Anikoto resolves the same title via fallback', async () => { + const sdk = createSdk({ + sources: ['anilist', 'anikoto'], + http: { timeoutMs: 25_000 }, + }); + const results = await sdk.search('Frieren', { kind: 'anime' }); + const hit = results.find((r) => r.mappings.anilist === 154587); + expect(hit).toBeDefined(); + + const sources = await sdk.sources(hit!); + const ak = sources.find((s) => s.id === 'anikoto'); + expect(ak).toBeDefined(); + expect(ak!.status).toBe('available'); + }, 90_000); + + it('MangaDex resolves a popular manga via fallback', async () => { + const sdk = createSdk({ + sources: ['anilist', 'mangadex'], + http: { timeoutMs: 25_000 }, + }); + const results = await sdk.search('Chainsaw Man', { kind: 'manga' }); + expect(results.length).toBeGreaterThan(0); + const hit = results[0]; + + const sources = await sdk.sources(hit); + const md = sources.find((s) => s.id === 'mangadex'); + expect(md).toBeDefined(); + expect(md!.status).toBe('available'); + }, 90_000); +}); diff --git a/tests/e2e/server.test.ts b/tests/e2e/server.test.ts index 8252ec7..05d549b 100644 --- a/tests/e2e/server.test.ts +++ b/tests/e2e/server.test.ts @@ -1,168 +1,73 @@ /** - * Live server E2E. - * - * Spawns the real `startServer({ ... })` HTTP server against the real - * AnilistMeta + AllmangaProvider and exercises every public route: - * - * - /health - * - /openapi.json - * - /search (content provider) - * - /meta/search (metadata provider) - * - /meta/info (metadata provider — verifies enrichment fields) - * - /meta/content (metadata → content cross-provider) - * - /meta/browse (trending) - * - * No mocks; the server is a real HTTP server, the providers do real - * upstream calls, and we hit the server through `fetch`. + * Integration test for startServer — 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 { HttpClient } from '../../src/transport/http.js'; -import { AnilistMeta } from '../../src/meta/AnilistMeta.js'; -import { AllmangaProvider } from '../../src/providers/AllmangaProvider.js'; import { startServer } from '../../src/server/index.js'; +import { createSdk } from '../../src/sdk.js'; let server: http.Server; let baseUrl: string; -const cache = new Map(); -beforeAll(async () => { - const httpClient = new HttpClient({ timeoutMs: 30_000 }); - server = startServer({ - providers: [new AllmangaProvider(httpClient)], - metaProviders: [new AnilistMeta(httpClient)], - proxy: false, - cache: { - get: (k) => cache.get(k), - set: (k, v) => { - cache.set(k, v); - }, - }, - // Bind to a random ephemeral port to avoid collisions with anything - // already running on :3000 in dev. - port: await getFreePort(), +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)); + }); }); - const addr = server.address(); - if (!addr || typeof addr === 'string') throw new Error('failed to bind'); +} + +beforeAll(async () => { + const port = await getFreePort(); + const sdk = createSdk({ sources: ['anilist'] }); + server = startServer({ 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((resolve) => server.close(() => resolve())); + await new Promise((r) => server.close(() => r())); }); -async function getJson(path: string): Promise<{ status: number; body: T }> { +async function get(path: string) { const res = await fetch(`${baseUrl}${path}`); - const body = (await res.json()) as T; - return { status: res.status, body }; + return { status: res.status, body: await res.json() }; } describe('startServer — live integration', () => { - it('/health returns ok with the registered providers', async () => { - const { status, body } = await getJson<{ - ok: boolean; - providers: string[]; - metaProviders: string[]; - }>('/health'); - expect(status).toBe(200); - expect(body.ok).toBe(true); - expect(body.providers).toContain('allmanga'); - expect(body.metaProviders).toContain('anilist'); - }); - - it('/openapi.json describes the meta routes', async () => { - const { status, body } = await getJson<{ paths: Record }>('/openapi.json'); + it('GET /health returns source health array', async () => { + const { status, body } = await get('/health'); expect(status).toBe(200); - expect(body.paths).toHaveProperty('/meta/search'); - expect(body.paths).toHaveProperty('/meta/info'); - expect(body.paths).toHaveProperty('/meta/content'); - expect(body.paths).toHaveProperty('/meta/stream'); - expect(body.paths).toHaveProperty('/meta/browse'); + expect(Array.isArray(body)).toBe(true); }); - it('/meta/search hits AniList live', async () => { - const { status, body } = await getJson>( - '/meta/search?provider=anilist&q=Cowboy%20Bebop', - ); - expect(status).toBe(200); - expect(body.length).toBeGreaterThan(0); - expect(body[0].id.startsWith('anilist:')).toBe(true); - }, 30_000); - - it('/meta/info returns full IMediaMetadata for anilist:1', async () => { - const { status, body } = await getJson<{ - id: string; - title: { english?: string }; - episodeCount?: number; - characters?: unknown[]; - streamingEpisodes?: unknown[]; - }>('/meta/info?provider=anilist&id=anilist:1'); - expect(status).toBe(200); - expect(body.id).toBe('anilist:1'); - expect(body.title.english).toBe('Cowboy Bebop'); - expect(body.episodeCount).toBe(26); - expect(Array.isArray(body.characters)).toBe(true); - expect((body.characters ?? []).length).toBeGreaterThan(0); - expect((body.streamingEpisodes ?? []).length).toBeGreaterThan(0); - }, 40_000); - - it('/meta/content resolves the AniList → AllManga mapping and returns episodes', async () => { - const { status, body } = await getJson>( - '/meta/content?provider=anilist&id=anilist:1&contentProvider=allmanga', - ); - expect(status).toBe(200); - expect(body.length).toBeGreaterThan(0); - expect(body[0].id.startsWith('allmanga:')).toBe(true); - }, 90_000); - - it('/meta/browse?kind=trending returns AniList trending', async () => { - const { status, body } = await getJson>( - '/meta/browse?provider=anilist&kind=trending&perPage=3', - ); + 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(body.length).toBeGreaterThan(0); - expect(body[0].id.startsWith('anilist:')).toBe(true); - }, 30_000); + 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('cached calls do not re-hit upstream', async () => { - // /meta/info already ran once; the second call should be a cache hit. - const before = cache.size; - const { status } = await getJson('/meta/info?provider=anilist&id=anilist:1'); + 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(cache.size).toBe(before); // nothing new written - }); + expect((body as any).items).toBeDefined(); + expect((body as any).items.length).toBeGreaterThan(0); + }, 30000); - it('returns 400 for missing required params', async () => { - const { status, body } = await getJson<{ error: string }>('/meta/search?q=foo'); + it('GET /search with no q returns 400', async () => { + const { status } = await get('/search'); expect(status).toBe(400); - expect(body.error).toMatch(/provider/i); }); - it('returns 404 for unknown routes under /meta/', async () => { - const { status } = await getJson('/meta/nope?provider=anilist'); + it('GET /unknown returns 404', async () => { + const { status } = await get('/does-not-exist'); expect(status).toBe(404); }); - - it('strict URN check rejects a mismatched meta URN with 400', async () => { - const { status, body } = await getJson<{ error: string }>( - '/meta/info?provider=anilist&id=mal:21', - ); - expect(status).toBe(400); - expect(body.error).toMatch(/does not match/); - }); }); - -async function getFreePort(): Promise { - return new Promise((resolve, reject) => { - const s = http.createServer(); - s.listen(0, () => { - const addr = s.address(); - if (!addr || typeof addr === 'string') { - reject(new Error('no address')); - return; - } - const port = addr.port; - s.close(() => resolve(port)); - }); - }); -} 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); }); diff --git a/tests/extractors.test.ts b/tests/extractors.test.ts index ab1212e..6f432d6 100644 --- a/tests/extractors.test.ts +++ b/tests/extractors.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { aesEncrypt, aesDecrypt } from '../src/utils/crypto.js'; -import { HttpClient } from '../src/transport/http.js'; +import { HttpClient } from '../src/internal/http.js'; import { VidstreamingExtractor } from '../src/extractors/VidstreamingExtractor.js'; describe('AES Cryptography Helpers', () => { diff --git a/tests/hlsUtils.test.ts b/tests/hlsUtils.test.ts index 5d279f2..d1a283e 100644 --- a/tests/hlsUtils.test.ts +++ b/tests/hlsUtils.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { HttpClient } from '../src/transport/http.js'; -import { HlsUtils } from '../src/transport/hlsUtils.js'; +import { HttpClient } from '../src/internal/http.js'; +import { HlsUtils } from '../src/internal/hls.js'; describe('HlsUtils', () => { it('should return unmodified manifest when no proxy is configured', () => { diff --git a/tests/http.test.ts b/tests/http.test.ts index 11b5b2c..6f00060 100644 --- a/tests/http.test.ts +++ b/tests/http.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { HttpClient } from '../src/transport/http.js'; +import { HttpClient } from '../src/internal/http.js'; describe('HttpClient', () => { it('should return original URL when no proxy is configured', () => { 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); + }); +}); diff --git a/tests/language.test.ts b/tests/language.test.ts deleted file mode 100644 index 4bbb5ee..0000000 --- a/tests/language.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Unit tests for ContentLanguage type system. - * Verifies that IContentUnit.language, IMediaSearchResult.availableLanguages, - * and IVideoPayload.language are wired correctly end-to-end. - */ -import { describe, it, expect } from 'vitest'; -import type { - ContentLanguage, - IContentUnit, - IMediaSearchResult, - IVideoPayload, -} from '../src/types/index.js'; - -describe('ContentLanguage type system', () => { - it('IContentUnit should carry a language field', () => { - const unit: IContentUnit = { - id: 'show-1/ep-1/sub', - title: 'Episode 1', - number: 1, - language: 'sub', - }; - expect(unit.language).toBe('sub'); - }); - - it('IContentUnit supports all three language values', () => { - const langs: ContentLanguage[] = ['sub', 'dub', 'raw']; - for (const lang of langs) { - const unit: IContentUnit = { - id: `id/${lang}`, - title: 'Test', - number: 1, - language: lang, - }; - expect(unit.language).toBe(lang); - } - }); - - it('IMediaSearchResult.availableLanguages is optional', () => { - const resultNoLang: IMediaSearchResult = { - id: 'abc', - title: 'Naruto', - catalogType: 'ANIME', - providerId: 'test', - }; - expect(resultNoLang.availableLanguages).toBeUndefined(); - - const resultWithLang: IMediaSearchResult = { - id: 'abc', - title: 'Naruto', - catalogType: 'ANIME', - providerId: 'test', - availableLanguages: ['sub', 'dub'], - }; - expect(resultWithLang.availableLanguages).toContain('sub'); - expect(resultWithLang.availableLanguages).toContain('dub'); - }); - - it('IVideoPayload.language is optional', () => { - const payloadNoLang: IVideoPayload = { - sourceUrl: 'https://example.com/video.mp4', - isHLS: false, - quality: 'auto', - }; - expect(payloadNoLang.language).toBeUndefined(); - - const payloadWithLang: IVideoPayload = { - sourceUrl: 'https://example.com/video.mp4', - isHLS: false, - quality: '1080p', - language: 'dub', - }; - expect(payloadWithLang.language).toBe('dub'); - }); - - it('IVideoPayload supports 480p quality', () => { - const payload: IVideoPayload = { - sourceUrl: 'https://example.com/video.mp4', - isHLS: false, - quality: '480p', - }; - expect(payload.quality).toBe('480p'); - }); -}); 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([]); + }); +}); diff --git a/tests/proxy.test.ts b/tests/proxy.test.ts new file mode 100644 index 0000000..99047d3 --- /dev/null +++ b/tests/proxy.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import * as crypto from 'node:crypto'; +import { proxifyStream, proxifyPages, buildProxyUrl } from '../src/server/proxy.js'; +import type { Stream, Pages } from '../src/types.js'; + +const BASE = 'http://localhost:3030'; + +function sign(secret: string, url: string, h?: string): string { + const hmac = crypto.createHmac('sha256', secret); + hmac.update(url); + if (h) hmac.update('|h=' + h); + return hmac.digest('hex'); +} + +describe('buildProxyUrl', () => { + it('encodes the target url', () => { + const u = buildProxyUrl(BASE, 'https://cdn/file?x=1', undefined, undefined); + expect(u).toBe(`${BASE}/proxy?url=${encodeURIComponent('https://cdn/file?x=1')}`); + }); + + it('appends a signature when a secret is supplied', () => { + const target = 'https://cdn/file.m3u8'; + const u = buildProxyUrl(BASE, target, undefined, 'shh'); + const sig = sign('shh', target); + expect(u).toContain(`&sig=${sig}`); + }); + + it('signs the headers payload when present', () => { + const target = 'https://cdn/file.m3u8'; + const h = Buffer.from(JSON.stringify({ Referer: 'https://x' })).toString('base64'); + const u = buildProxyUrl(BASE, target, h, 'shh'); + const sig = sign('shh', target, h); + expect(u).toContain(`&h=${encodeURIComponent(h)}`); + expect(u).toContain(`&sig=${sig}`); + }); +}); + +describe('proxifyStream', () => { + it('rewrites url and subtitles to go through /proxy', () => { + const stream: Stream = { + url: 'https://cdn/ep1.m3u8', + source: 'test', + server: 'cdn', + quality: '1080p', + language: 'sub', + isHls: true, + subtitles: [{ url: 'https://subs/en.vtt', language: 'en', label: 'English', format: 'vtt' }], + headers: { Referer: 'https://prov.com/' }, + }; + + const out = proxifyStream(stream, BASE, undefined); + expect(out.url.startsWith(`${BASE}/proxy?url=`)).toBe(true); + expect(out.subtitles[0].url.startsWith(`${BASE}/proxy?url=`)).toBe(true); + expect(out.subtitles[0].url).toContain('ct=text%2Fvtt'); + }); + + it('encodes the headers payload into every URL', () => { + const stream: Stream = { + url: 'https://cdn/ep1.m3u8', + source: 'test', + server: 'cdn', + quality: 'auto', + language: 'sub', + isHls: true, + subtitles: [], + headers: { Referer: 'https://prov.com/' }, + }; + + const out = proxifyStream(stream, BASE, undefined); + expect(out.url).toContain('h='); + const h = new URL(out.url).searchParams.get('h')!; + const decoded = JSON.parse(Buffer.from(h, 'base64').toString('utf8')); + expect(decoded.Referer).toBe('https://prov.com/'); + }); +}); + +describe('proxifyPages', () => { + it('rewrites every page URL', () => { + const pages: Pages = { + pages: [{ url: 'https://img/1.jpg' }, { url: 'https://img/2.jpg' }], + }; + const out = proxifyPages(pages, BASE, undefined); + expect(out.pages[0].url.startsWith(`${BASE}/proxy?url=`)).toBe(true); + expect(out.pages[1].url.startsWith(`${BASE}/proxy?url=`)).toBe(true); + }); +}); diff --git a/tests/rateLimiter.test.ts b/tests/rateLimiter.test.ts index 8714f55..0d29b37 100644 --- a/tests/rateLimiter.test.ts +++ b/tests/rateLimiter.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { RateLimiter } from '../src/transport/rateLimiter.js'; +import { RateLimiter } from '../src/internal/rateLimiter.js'; describe('RateLimiter', () => { it('acquires immediately when under capacity', async () => { diff --git a/tests/registry.test.ts b/tests/registry.test.ts new file mode 100644 index 0000000..2c01317 --- /dev/null +++ b/tests/registry.test.ts @@ -0,0 +1,187 @@ +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'; +import { encodeId } from '../src/internal/id.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' }, + source: 'stub', + 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: {} }; + const ranked = await reg.rankPlaybackSources(media, {}); + expect(ranked).toHaveLength(1); + expect(ranked[0].status).toBe('incompatible'); + }); + + it('resolveMediaId falls back to title search when lookupByMapping returns null', async () => { + const reg = new Registry(); + const calls: { query: string }[] = []; + const playback: Source = { + id: 'play', + kinds: ['anime'], + caps: { search: true, episodes: true, mapping: true }, + async lookupByMapping() { + return null; + }, + async search(q) { + calls.push({ query: q }); + return [ + { + id: encodeId({ t: 'media', s: 'play', r: 'native-id-42' }), + kind: 'anime', + title: { preferred: "Frieren: Beyond Journey's End" }, + source: 'play', + mappings: {}, + year: 2023, + }, + { + id: encodeId({ t: 'media', s: 'play', r: 'wrong-show' }), + kind: 'anime', + title: { preferred: 'Totally Unrelated Show' }, + source: 'play', + mappings: {}, + year: 2023, + }, + ]; + }, + async episodes() { + return { items: [] }; + }, + }; + reg.register(playback); + + const media: Media = { + ...FAKE_MEDIA, + title: { + preferred: "Frieren: Beyond Journey's End", + english: "Frieren: Beyond Journey's End", + }, + year: 2023, + mappings: { anilist: 154587 }, + }; + + const resolved = await reg.resolveMediaId(media, playback, {}); + expect(resolved).toBe('native-id-42'); + expect(calls.length).toBeGreaterThan(0); + // Result is cached internally — subsequent calls skip the search. + const resolved2 = await reg.resolveMediaId(media, playback, {}); + expect(resolved2).toBe('native-id-42'); + }); + + it('resolveMediaId rejects fuzzy matches whose year disagrees by more than 1', async () => { + const reg = new Registry(); + const playback: Source = { + id: 'play', + kinds: ['anime'], + caps: { search: true, episodes: true }, + async search() { + return [ + { + // Same title, but wrong year — should be rejected. + id: encodeId({ t: 'media', s: 'play', r: 'wrong-year' }), + kind: 'anime', + title: { preferred: 'Naruto' }, + source: 'play', + mappings: {}, + year: 2007, + }, + ]; + }, + async episodes() { + return { items: [] }; + }, + }; + reg.register(playback); + + const media: Media = { + ...FAKE_MEDIA, + title: { preferred: 'Naruto' }, + year: 2002, + }; + + const resolved = await reg.resolveMediaId(media, playback, {}); + expect(resolved).toBeNull(); + }); + + 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); + }); +}); diff --git a/tests/retry.test.ts b/tests/retry.test.ts index a1791a9..3809c21 100644 --- a/tests/retry.test.ts +++ b/tests/retry.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { withRetry, HttpRetryableError, parseRetryAfter } from '../src/transport/retry.js'; +import { withRetry, HttpRetryableError, parseRetryAfter } from '../src/internal/retry.js'; describe('parseRetryAfter', () => { it('parses seconds', () => { 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'); + }); +}); diff --git a/tests/similarity.test.ts b/tests/similarity.test.ts index 2399ed7..5049fc8 100644 --- a/tests/similarity.test.ts +++ b/tests/similarity.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { normalizeTitle, diceSimilarity, bestSimilarity } from '../src/meta/similarity.js'; +import { normalizeTitle, diceSimilarity, bestSimilarity } from '../src/internal/similarity.js'; describe('normalizeTitle', () => { it('lowercases and strips punctuation', () => { diff --git a/tests/types.test.ts b/tests/types.test.ts new file mode 100644 index 0000000..9ad4393 --- /dev/null +++ b/tests/types.test.ts @@ -0,0 +1,91 @@ +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' }, + source: 'anilist', + mappings: { anilist: 154587 }, + }; + expect(JSON.parse(JSON.stringify(m))).toEqual(m); + }); + + it('Episode round-trips through JSON', () => { + const ep: Episode = { + id: 'ep-id', + number: 1, + languages: ['sub'], + }; + expect(JSON.parse(JSON.stringify(ep))).toEqual(ep); + }); + + it('Chapter round-trips through JSON', () => { + const ch: Chapter = { id: 'ch-id', number: 1 }; + 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); + }); +}); diff --git a/tests/urn.test.ts b/tests/urn.test.ts deleted file mode 100644 index ac485fa..0000000 --- a/tests/urn.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - buildUrn, - buildTypedUrn, - isUrn, - parseTypedUrn, - parseUrn, - strictUnwrapUrn, - unwrapUrn, -} from '../src/utils/urn.js'; - -describe('URN helpers', () => { - it('builds a URN with provider:raw shape', () => { - expect(buildUrn('allmanga', 'abc123')).toBe('allmanga:abc123'); - }); - - it('parses a simple URN', () => { - expect(parseUrn('allmanga:abc123')).toEqual({ providerId: 'allmanga', rawId: 'abc123' }); - }); - - it('treats only the first colon as separator', () => { - expect(parseUrn('animeparadise:abc:xyz')).toEqual({ - providerId: 'animeparadise', - rawId: 'abc:xyz', - }); - }); - - it('falls back to bare-id behaviour when no colon present', () => { - expect(parseUrn('legacy-id')).toEqual({ providerId: '', rawId: 'legacy-id' }); - }); - - it('unwrapUrn strips matching prefix only', () => { - expect(unwrapUrn('allmanga', 'allmanga:abc')).toBe('abc'); - expect(unwrapUrn('gogoanime', 'allmanga:abc')).toBe('allmanga:abc'); - expect(unwrapUrn('allmanga', 'no-prefix-id')).toBe('no-prefix-id'); - }); - - it('preserves raw IDs containing colons after the first one', () => { - expect(unwrapUrn('animeparadise', 'animeparadise:uid123:animeId456')).toBe('uid123:animeId456'); - }); - - it('preserves raw IDs containing slashes', () => { - expect(unwrapUrn('allmanga', 'allmanga:5jzpRTJWnubrgHm5G/1')).toBe('5jzpRTJWnubrgHm5G/1'); - expect(unwrapUrn('gogoanime', 'gogoanime:/watch/one-piece/ep-1')).toBe('/watch/one-piece/ep-1'); - }); - - it('isUrn recognizes the right shape', () => { - expect(isUrn('allmanga:abc')).toBe(true); - expect(isUrn('allmanga:abc', 'allmanga')).toBe(true); - expect(isUrn('allmanga:abc', 'gogoanime')).toBe(false); - expect(isUrn('no-colon')).toBe(false); - expect(isUrn(':leading-colon')).toBe(false); - }); - - it('round-trips build → unwrap', () => { - const cases = ['abc', 'with/slash', 'with:colon', 'multi:slash/path:and:colon']; - for (const raw of cases) { - const urn = buildUrn('p', raw); - expect(unwrapUrn('p', urn)).toBe(raw); - } - }); - - it('rejects bad input on build', () => { - expect(() => buildUrn('', 'x')).toThrow(); - expect(() => buildUrn('p', null as any)).toThrow(); - }); -}); - -describe('strictUnwrapUrn', () => { - it('returns the raw ID when the prefix matches', () => { - expect(strictUnwrapUrn('anilist', 'anilist:21')).toBe('21'); - }); - - it('throws on a wrong prefix', () => { - expect(() => strictUnwrapUrn('anilist', 'mal:21')).toThrow(/does not match/); - }); - - it('throws on a bare (un-prefixed) ID', () => { - expect(() => strictUnwrapUrn('anilist', '21')).toThrow(/bare/); - }); -}); - -describe('typed URN helpers', () => { - it('buildTypedUrn produces provider:kind:rawId', () => { - expect(buildTypedUrn('mal', 'anime', 21)).toBe('mal:anime:21'); - expect(buildTypedUrn('kitsu', 'manga', 'abc')).toBe('kitsu:manga:abc'); - }); - - it('parseTypedUrn extracts kind + rawId when present', () => { - expect(parseTypedUrn('mal', 'mal:anime:21')).toEqual({ kind: 'anime', rawId: '21' }); - expect(parseTypedUrn('mal', 'mal:manga:13')).toEqual({ kind: 'manga', rawId: '13' }); - }); - - it('parseTypedUrn returns undefined kind for bare/untyped URNs', () => { - expect(parseTypedUrn('mal', 'mal:21')).toEqual({ rawId: '21' }); - expect(parseTypedUrn('mal', '21')).toEqual({ rawId: '21' }); - }); - - it('parseTypedUrn passes through wrong-provider URNs untouched', () => { - expect(parseTypedUrn('mal', 'anilist:21')).toEqual({ rawId: 'anilist:21' }); - }); -}); 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/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 + 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.

vibeplayer embeds to a direct HLS manifest with proxy-aware chunk URI rewriting.`, + description: `Scrapes episode pages on anineko.to. Resolves vibeplayer embeds to a direct HLS manifest.`, }, { id: 'anikoto', @@ -70,7 +70,7 @@ const providers = [ method: 'HTML scrape', methodStyle: 'background:#3b82f6;color:#eff6ff', tags: ['DOM', 'Referer'], - description: `Scrapes weebcentral.com. Extracts high-quality chapter image pages with Referer protection and automatic proxy routing.`, + description: `Scrapes weebcentral.com. Extracts high-quality chapter image pages.`, }, { id: 'mangapill', diff --git a/website/src/components/sections/Proxy.astro b/website/src/components/sections/Proxy.astro index 8069312..1a3c548 100644 --- a/website/src/components/sections/Proxy.astro +++ b/website/src/components/sections/Proxy.astro @@ -1,27 +1,33 @@ --- -const proxyEnableCode = `startServer({ - providers: [new GogoanimeProvider(new HttpClient())], - port: 3000, - proxy: true, // ← that's it +const proxyEnableCode = `// enable /proxy + automatic URL rewriting +import { startServer } from 'anime-sdk'; +startServer({ + port: 3030, + proxy: { + signSecret: process.env.PROXY_SIGN_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..1b158e5 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 +$ npx anime-sdk -const store = new Map(); +// or programmatically: +import { startServer } from 'anime-sdk'; +startServer({ 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>`; +// or with a custom SDK instance: +import { createSdk, startServer } from 'anime-sdk'; +startServer({ sdk: createSdk({ sources: ['anilist'] })});`; ---
@@ -38,9 +33,8 @@ 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. Point any client at it, ship.

@@ -49,7 +43,7 @@ const serverAuthCode = `startServer
Start it - server.ts + terminal / server.ts
     
@@ -67,12 +61,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/content/docs/docs/api-reference.mdx b/website/src/content/docs/docs/api-reference.mdx index 3c2d93c..c7f39a5 100644 --- a/website/src/content/docs/docs/api-reference.mdx +++ b/website/src/content/docs/docs/api-reference.mdx @@ -1,829 +1,380 @@ --- title: API Reference -description: Complete type and interface documentation for anime-sdk. +description: Complete type and function documentation for anime-sdk. --- -## Types - -### `ContentLanguage` - -```ts -type ContentLanguage = 'sub' | 'dub' | 'raw'; -``` - -| Value | Meaning | -| ------- | ------------------------------ | -| `'sub'` | Original audio with subtitles | -| `'dub'` | Dubbed audio (usually English) | -| `'raw'` | Original audio, no subtitles | - -### `MediaCatalogType` +## `createSdk(opts?)` ```ts -type MediaCatalogType = 'ANIME' | 'MOVIE' | 'TV' | 'MANGA'; +import { createSdk } from 'anime-sdk'; +const sdk = createSdk(opts?); ``` -Supported types include `'ANIME'` and `'MANGA'`. +Factory — instantiates the SDK with all 12 sources registered. -### `Urn` +### `SdkOptions` ```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. - -### `CallOptions` - -```ts -interface CallOptions { - /** Cancels the in-flight call. Threaded into fetch + rate limiter + retry. */ - 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'; +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; + }; } ``` -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` - -Returned by `provider.search()`. - -```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; -} -``` - -### `IContentUnit` - -Returned by `provider.fetchContentUnits()`. Represents a single episode or chapter in a unified, language-agnostic list: the caller picks a translation at `resolveStream` time. +--- -```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 methods -### `ISubtitleTrack` / `ISubtitleAvailability` +### `sdk.search(query, opts?)` ```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; -} +sdk.search(query: string, opts?: { + kind?: 'anime' | 'manga'; // default: 'anime' + signal?: AbortSignal; +}): ProgressiveResult ``` -`ISubtitleAvailability` is the metadata-only shape used in list contexts; `ISubtitleTrack` adds the URL once resolved. - -### `IUnitTracks` +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 the optional `provider.fetchUnitTracks()`. Lets a UI introspect tracks without paying the cost of a full stream resolution. +### `sdk.info(media, opts?)` ```ts -interface IUnitTracks { - subtitles: ISubtitleTrack[]; - qualities: IVideoPayload['quality'][]; - headers?: Record; // forwarded to the subtitle/stream fetcher when present -} +sdk.info(media: Media | string, opts?: { signal?: AbortSignal }): Promise ``` -### `IVideoPayload` +Full info for a single title. Accepts a `Media` object or an opaque `id` string. -A single playable stream. +### `sdk.sources(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 -} +sdk.sources(media: Media | string, opts?: { signal?: AbortSignal }): Promise ``` -### `SdkCache` +Returns playback sources ranked by health × coverage. `status: 'available'` means the source has this title. -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. +### `sdk.episodes(media, opts?)` ```ts -interface SdkCache { - get(key: string): unknown | Promise; - set(key: string, value: unknown): void | Promise; -} +sdk.episodes(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.chapters(media, opts?)` ```ts -interface IMangaPayload { - imageUrls: string[]; - headers?: Record; -} +sdk.chapters(media: Media | string, opts?: { + signal?: AbortSignal; + cursor?: string; + limit?: number; +}): Promise> ``` -### `ResolvedMediaStream` - -Returned by `provider.resolveStream()`. A discriminated union: always check `type` before accessing the payload. +### `sdk.stream(episode, opts?)` ```ts -type ResolvedMediaStream = - | { type: 'video'; streams: IVideoPayload[] } - | { type: 'manga'; pages: IMangaPayload }; -``` - -Anime providers return `type: 'video'`; manga providers return `type: 'manga'`. The `streams` array on video results is sorted best-first by the provider. - ---- - -## HttpClient - -The shared HTTP transport. All providers and extractors accept one via constructor. - -```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.stream(episode: Episode | string, opts?: { + signal?: AbortSignal; +}): ProgressiveResult ``` -### Rate limiting + retry + AbortSignal +Returns **all streams** available for the episode — all sources, all servers, all languages — as a `ProgressiveResult` (async-iterable or `await`able for the full array). Each `Stream` is one playable URL with `source`, `server`, `quality`, and `language` metadata; the frontend picks the preferred one. -`HttpClient.request` composes three middlewares around the underlying -transport, each on by default: +With an `Episode` object, the SDK auto-fans-out across all enabled playback sources (using internally cached media from the prior `episodes()` call). With a string ID, only the source encoded in the ID is queried. -- **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.pages(chapter, opts?)` ```ts -const ac = new AbortController(); -setTimeout(() => ac.abort(), 1500); -await http.get('https://graphql.anilist.co', { signal: ac.signal }); +sdk.pages(chapter: Chapter | string, opts?: { signal?: AbortSignal }): Promise ``` -### 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`. +No `language` argument — manga has no language axis. -### Methods +### `sdk.browse(opts)` ```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 +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> ``` -### 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. - ---- - -## URN helpers - -Every `id` flowing through the SDK is a URN of shape -`${providerId}:${rawId}`. Helpers live in `utils/urn.ts`: +### `sdk.health()` ```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 } +sdk.health(): SourceHealth[] ``` -`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`). +Synchronous. Returns rolling success/latency stats per source. --- -## BaseProvider - -```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; -} -``` - ---- - -## Metadata layer - -### `IMediaMetadata` +## Value types -The full normalized metadata record returned by -`metaProvider.fetchMediaInfo`. Includes everything the catalogue ships: +### `Media` ```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; +interface Media { + id: string; // opaque base64url token — pass to SDK methods + kind: 'anime' | 'manga'; + title: MediaTitle; + cover?: MediaCover; banner?: string; - status?: MediaStatus; - format?: MediaFormat; + 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; - 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 + description?: string; + source: string; // which source produced this record + mappings: { + anilist?: number; + mal?: number; + kitsu?: number; + }; } ``` -### `BaseMetadataProvider` +### `MediaTitle` ```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 MediaTitle { + preferred: string; // best available: english ?? romaji ?? native + english?: string; + romaji?: string; + native?: string; } - -type BrowseKind = 'trending' | 'popular' | 'seasonal' | 'top'; ``` -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. +### `MediaCover` ```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 MediaCover { + url: string; + color?: string; // dominant color in hex (e.g. '#e4a15d') } ``` -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 +### `Score` ```ts -abstract class BaseExtractor { - abstract readonly id: string; - constructor(protected http: HttpClient); - abstract extract(embedUrl: string): Promise; +interface Score { + value: number; // e.g. 87 + scale: number; // e.g. 100 } +// Display as: (score.value / score.scale * 10).toFixed(1) → "8.7" ``` -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. +### `Episode` ```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 Episode { + id: string; // opaque — pass to sdk.stream() + number: number; + title?: string; + thumbnail?: string; + airDate?: string; + filler?: boolean; + recap?: boolean; + languages?: ('sub' | 'dub' | 'raw')[]; } ``` -**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` - -Extracts direct MP4 URLs from `mp4upload.com` embed pages. +### `Chapter` ```ts -class Mp4UploadExtractor extends BaseExtractor { - readonly id = 'mp4upload'; - static matches(url: string): boolean; // true for mp4upload.com URLs - extract(embedUrl: string): Promise; +interface Chapter { + id: string; // opaque — pass to sdk.pages() + number: number; + title?: string; } ``` -Parses the `player.src({ src: "https://...video.mp4" })` line from the embed HTML. Returned URLs require `{ Referer: 'https://mp4upload.com/' }`. +### `Stream` -### `GenericHlsExtractor` - -Best-effort extractor that scans any embed page for a `.m3u8` or `.mp4` URL. +Each `Stream` is one playable URL. Sources that offer multiple servers or qualities return multiple `Stream` objects. ```ts -class GenericHlsExtractor extends BaseExtractor { - readonly id = 'generic-hls'; - extract(embedUrl: string): Promise; +interface Stream { + url: string; + source: string; // which source provided this stream + server: string; // server within the source (e.g. 'mp4upload', 'wixmp') + quality: '1080p' | '720p' | '480p' | '360p' | 'auto'; + language: 'sub' | 'dub' | 'raw'; + isHls: boolean; + headers?: Record; + subtitles: Subtitle[]; } ``` -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. +### `Pages` ```ts -class VidstreamingExtractor extends BaseExtractor { - readonly id = 'vidstreaming'; - extract(embedUrl: string): Promise; +interface Pages { + pages: { url: string; width?: number; height?: 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 +### `Subtitle` ```ts -class HlsUtils { - static rewriteManifest(manifestText: string, playlistUrl: string, httpClient: HttpClient): string; +interface Subtitle { + url: string; + language: string; // BCP-47 (e.g. 'en', 'pt-BR') + label: string; // human-readable ('English') + format: 'vtt' | 'srt' | 'ass'; } ``` -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 +### `List` ```ts -class DomRegistry { - static register(customParser: IDomParser): void; - static getParser(): IDomParser; - static parse(html: string): IDomElement; +interface List { + items: T[]; + nextCursor?: string; // undefined when exhausted + total?: number; } ``` -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. - -**Custom parser:** +### `SourceInfo` ```ts -import { DomRegistry, IDomParser, IDomElement } from 'anime-sdk'; - -class MyParser implements IDomParser { - parse(html: string): IDomElement { - /* ... */ - } +interface SourceInfo { + id: string; + status: 'available' | 'incompatible' | 'error'; + episodeCount?: number; + successRate?: number; // rolling 0–1 from last 20 calls } - -DomRegistry.register(new MyParser()); ``` -### `IDomElement` / `IDomParser` +### `ProgressiveResult` ```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; +interface ProgressiveResult extends AsyncIterable, PromiseLike { + cancel(): void; } ``` --- -## startServer +## Errors + +### `AniError` ```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; +class AniError extends Error { + readonly code: AniErrorCode; + readonly source?: string; // source id, when known + readonly retryable: boolean; + readonly cause?: unknown; } ``` -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. - -Routes: - -- **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` - ---- - -## Download utilities - -Functions for saving resolved streams to disk. Requires Node 20+; `ffmpeg` on `PATH` for HLS video downloads only. - -### `downloadVideo` +### `AniErrorCode` ```ts -function downloadVideo( - streams: IVideoPayload | IVideoPayload[], - outputPath: string, - options?: DownloadVideoOptions, -): Promise; +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; ``` -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. +--- + +## Server -- **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`. +### `startServer(opts?)` ```ts -interface DownloadVideoOptions { - onProgress?: (info: { phase: string; detail?: string }) => void; - timeoutMs?: number; // default 300_000 (5 min) -} +import { startServer } from 'anime-sdk'; -interface DownloadVideoResult { - outputPath: string; - stream: IVideoPayload; // the candidate that succeeded - fileSize: number; // bytes -} +function startServer(opts?: { + port?: number; // default: 0 (random); set PORT env var for 3030 + sdk?: Sdk; // default: createSdk() + proxy?: ProxyOptions; // enable /proxy + URL rewriting +}): http.Server; ``` -Progress `phase` values: `'resolving'` → `'downloading'` → `'muxing'` → `'complete'`. +Returns an `http.Server`. `server.close()` to shut down. See [HTTP Server](/docs/http-server/) for all routes and [Stream Proxy](/docs/proxy/) for proxy configuration. -### `downloadMangaPage` +### `ProxyOptions` ```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; +interface ProxyOptions { + base?: string; // public base URL override (defaults to Host header) + signSecret?: string; // HMAC-SHA256 sign every proxied URL + allowedHosts?: string[]; // suffix-matched SSRF allowlist } ``` -### `downloadMangaChapter` +--- -```ts -function downloadMangaChapter( - pages: IMangaPayload, - outputPath: string, - options?: DownloadMangaChapterOptions, -): Promise; -``` +## Download utilities -Downloads all pages and packages them as an uncompressed `.zip` archive (STORE method: images are already compressed). No external dependencies. +### `downloadVideo(stream, outputPath, opts?)` ```ts -interface DownloadMangaChapterOptions { - onProgress?: (info: { downloaded: number; total: number }) => void; - timeoutMs?: number; // per page; default 30_000 -} +import { downloadVideo } from 'anime-sdk'; -interface DownloadMangaChapterResult { - outputPath: string; - pageCount: number; - fileSize: number; // total ZIP size in bytes -} +function downloadVideo( + stream: Stream, + outputPath: string, + opts?: { + onProgress?: (info: { phase: string; detail?: string }) => void; + timeoutMs?: number; // default 300_000 (5 min) + headers?: Record; // override stream.headers + }, +): Promise<{ outputPath: string; fileSize: number }>; ``` -### HLS helpers +HLS streams: walks master → variant, downloads every segment (stripping PNG-disguised TS bytes), then `ffmpeg -c copy` muxes to MP4. Direct MP4 streams to disk. Each `Stream` is one URL — if it fails, the caller should try the next `Stream`. -Low-level utilities exported for custom pipelines: +### `downloadMangaChapter(pages, outputPath, 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 - -Exported helpers used internally by `AnimeParadiseProvider` and `startServer`; available for custom providers and self-hosted setups. +import { downloadMangaChapter } from 'anime-sdk'; -```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 downloadMangaChapter( + pages: Pages, + outputPath: string, + opts?: { + onProgress?: (info: { downloaded: number; total: number }) => void; + timeoutMs?: number; // per page; default 30_000 + headers?: Record; + }, +): Promise<{ outputPath: string; pageCount: number; fileSize: number }>; ``` ---- - -## Crypto utilities +Packages all pages as an uncompressed `.zip`. -Exported for use in custom providers. +### `downloadMangaPage(pages, pageIndex, outputDir, opts?)` ```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 +import { downloadMangaPage } from 'anime-sdk'; -// SHA-256 hash -sha256(text: string): Promise - -// AES-CTR decrypt (used by AllmangaProvider) -aesDecryptCtr(ciphertext: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise +function downloadMangaPage( + pages: Pages, + pageIndex: number, + outputDir: string, + opts?: { headers?: Record; timeoutMs?: number }, +): Promise<{ outputPath: string; pageIndex: number; fileSize: number; contentType: string }>; ``` - -All functions use `globalThis.crypto.subtle` (available in Node 20+ and all modern browsers). diff --git a/website/src/content/docs/docs/contributing.mdx b/website/src/content/docs/docs/contributing.mdx index cb524f8..a1bc7b8 100644 --- a/website/src/content/docs/docs/contributing.mdx +++ b/website/src/content/docs/docs/contributing.mdx @@ -1,204 +1,200 @@ --- 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`, `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() }, + source: 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') ?? '' }), + number: i + 1, + title: `Episode ${i + 1}`, + languages: ['sub'], + }), + ), + }; } - 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'); + + return payloads.map((p): Stream => { + let server = this.id; + try { + server = new URL(p.sourceUrl).hostname; + } catch {} + return { + url: p.sourceUrl, + source: this.id, + server, + quality: p.quality ?? 'auto', + language: 'sub', + isHls: p.isHLS, + headers: p.headers, + subtitles: [], + }; + }); } } ``` -### 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 +205,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 +213,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 +240,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[]` — one per playable URL (per server per language). Each `Stream` must include `source: this.id`, `server`, `quality`, `language`, `url`, `isHls`, `subtitles` +- [ ] 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..a50548f 100644 --- a/website/src/content/docs/docs/download.mdx +++ b/website/src/content/docs/docs/download.mdx @@ -1,237 +1,83 @@ --- title: Downloads -description: Save anime episodes as MP4 and manga chapters as ZIP archives using anime-sdk's built-in download utilities. +description: Save anime episodes as MP4 and manga chapters as ZIP archives. --- -The SDK ships built-in download utilities for both anime and manga. No extra dependencies beyond `ffmpeg` on your `PATH` (anime only). +The SDK ships built-in download utilities for both anime and manga. `ffmpeg` is needed on your `PATH` for HLS video muxing; manga downloads have no external dependency. ## 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(); +const results = await sdk.search('Frieren', { kind: 'anime' }); +const { items: episodes } = await sdk.episodes(results[0]); +const streams = await sdk.stream(episodes[0]); +const stream = streams.find((s) => s.language === 'sub') ?? streams[0]; -if (result.type === 'video') { - const download = await downloadVideo(result.streams, './episode-1.mp4', { - onProgress: ({ phase, detail }) => console.log(`[${phase}] ${detail ?? ''}`), - }); - - console.log(`Saved ${download.fileSize} bytes → ${download.outputPath}`); -} +const result = await downloadVideo(stream, './episode-1.mp4', { + onProgress: ({ phase, detail }) => console.log(`[${phase}] ${detail ?? ''}`), +}); +console.log(`Saved ${result.fileSize} bytes → ${result.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. +- **HLS streams**: every segment is fetched, PNG-disguised segments are stripped, then `ffmpeg -c copy` muxes the result to MP4. +- **Direct MP4**: streamed to disk via `fetch`. -```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'); -``` +Each `Stream` is one URL. If it fails, try the next `Stream` from the array. ### 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. - ---- - -## Manga: full chapter as ZIP +## Manga: `downloadMangaChapter` ```ts -import { MangadexProvider, HttpClient, downloadMangaChapter } 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); +import { downloadMangaChapter } from 'anime-sdk'; -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 pages = await sdk.pages(chapter); +const result = await downloadMangaChapter(pages, './chapter-1.zip', { + onProgress: ({ downloaded, total }) => console.log(`${downloaded}/${total}`), +}); +console.log(`${result.pageCount} pages → ${result.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 packaged as an uncompressed `.zip` (STORE method — images are already JPEG/PNG/WebP, so deflation would be wasted work). ---- - -## Downloading via the metadata layer +## Manga: `downloadMangaPage` -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: +Single page to disk: ```ts -import { HttpClient, AnilistMeta, AllmangaProvider, downloadVideo } from 'anime-sdk'; - -const http = new HttpClient(); -const meta = new AnilistMeta(http); -const allmanga = new AllmangaProvider(http); +import { downloadMangaPage } from 'anime-sdk'; -const result = await meta.resolveStream('anilist:1', 1, allmanga, 'sub'); -if (result.type === 'video') { - await downloadVideo(result.streams, './cowboy-bebop-ep1.mp4'); -} +const result = await downloadMangaPage(pages, 0, './scratch/'); +// → './scratch/page_001.jpg', 'image/jpeg', ~340 KB ``` ---- - -## Batch download +## HTTP server endpoints -`downloadVideo` is safe to run concurrently per episode. Use `Promise.allSettled` to continue even if individual downloads fail: +When the bundled server is running, downloads are exposed over HTTP via SSE-progress + token-served file: -```ts -const episodes = await provider.fetchContentUnits(shows[0].id); - -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`, { - onProgress: ({ phase }) => console.log(`ep${ep.number} [${phase}]`), - }); - }), -); - -for (const r of results) { - if (r.status === 'fulfilled') console.log('ok:', r.value?.outputPath); - else console.error('failed:', r.reason); -} +```text +GET /download/video/progress?episodeId=…&language=sub +GET /download/video/file?token=… +GET /download/manga/chapter/progress?chapterId=… +GET /download/manga/chapter/file?token=… ``` ---- +The progress endpoint streams Server-Sent Events while the download runs and returns a `{ type: 'complete', token }` message when finished. The frontend then GETs `/file?token=…` to pull the bytes — the token is single-use and expires after 10 minutes. -## API types +## Requirements -```ts -interface DownloadVideoOptions { - onProgress?: (info: { phase: string; detail?: string }) => void; - timeoutMs?: number; // default 300_000 (5 min) -} - -interface DownloadVideoResult { - outputPath: string; - stream: IVideoPayload; // the candidate that succeeded - fileSize: number; // bytes -} - -interface DownloadMangaPageOptions { - headers?: Record; // override the headers on IMangaPayload - timeoutMs?: number; // default 30_000 -} - -interface DownloadMangaPageResult { - outputPath: string; - pageIndex: number; - fileSize: number; - contentType: string; -} - -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 file size in bytes -} -``` - ---- - -## 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) -parseHlsMaster(content: string, baseUrl: string): string[] - -// Parse segment URLs + durations from an HLS media playlist -parseHlsSegments(content: string, baseUrl: string): Array<{ url: string; duration: number }> - -// 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 -createZipBuffer(entries: Array<{ filename: string; data: Buffer }>): Buffer -``` +- `ffmpeg` on `PATH` for HLS video downloads (not needed for direct MP4 or manga). +- Node 20+. diff --git a/website/src/content/docs/docs/http-server.mdx b/website/src/content/docs/docs/http-server.mdx index 25e0956..6b84926 100644 --- a/website/src/content/docs/docs/http-server.mdx +++ b/website/src/content/docs/docs/http-server.mdx @@ -3,363 +3,226 @@ 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 { startServer, createSdk } from 'anime-sdk'; + +// Zero config — SDK auto-constructed +startServer({ port: 3030 }); -// server is an http.Server — you can call server.close() to shut it down +// With a custom SDK instance: +startServer({ + port: 3030, + sdk: createSdk({ sources: ['anilist', 'megaplay'] }), +}); ``` -The server logs `anime-sdk server listening on http://localhost:3000` when ready. +`startServer` 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, + "source": "anilist", + "mappings": { "anilist": 154587, "mal": 52991 } } ] ``` -### `GET /content` - -List episodes for a show or chapters for a manga. One call returns the unified, language-agnostic list — each unit advertises its translations. +### `GET /media/:id` -| 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", + "number": 1, + "title": "Episode 1", + "languages": ["sub", "dub"] } ] } ``` -**Manga response:** - -```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. +### `GET /media/:id/chapters?cursor=…` -### `GET /tracks` +List chapters (manga). Same shape as episodes. -Inspect subtitle + quality availability for a unit **without** resolving the playable stream. Useful for populating a subtitle selector before the user hits play. +### `GET /media/:id/sources` -| 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"] -} +[ + { "id": "megaplay", "status": "available", "successRate": 0.97 }, + { "id": "allmanga", "status": "incompatible" } +] ``` -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` +### `GET /episode/:id/streams` -```sh -curl 'localhost:3000/meta/search?provider=anilist&q=Cowboy%20Bebop' -``` - -### `GET /meta/info` +Resolve **all** available streams — all sources, all servers, all languages — as a Server-Sent Events stream. Each SSE event is one `Stream` object emitted as it arrives. Cross-source fan-out happens automatically when the SDK has cached media from a prior `episodes` call. ```sh -curl 'localhost:3000/meta/info?provider=anilist&id=anilist:1' +curl 'localhost:3030/episode/EPISODE_ID/streams' ``` -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). +Each SSE event is a `Stream`. The client picks the preferred stream by `language`, `source`, and `server`. -The server validates that the URN's prefix matches the meta provider — -hitting `/meta/info?provider=anilist&id=mal:21` returns 400. +### `GET /episode/:id/stream?language=sub` -### `GET /meta/content` +Returns a single stream from the primary source. Used internally by the download endpoint. For UI, prefer `/streams`. -List episodes/chapters for a meta URN on a specific content provider. -Mapping is automatic. +| Param | Values | Default | +| ---------- | ----------------------- | ------- | +| `language` | `sub` \| `dub` \| `raw` | `sub` | ```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` +Response: `Stream` -Resolve a stream / tracks by metadata + episode number: - -```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", + "source": "allmanga", + "server": "cdn.example.com", + "quality": "auto", + "language": "dub", + "isHls": true, + "subtitles": [], + "headers": { "Referer": "https://megaplay.buzz/" } +} ``` -### `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. - -## Signing `/proxy` URLs +Response: `Pages` -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" + } + ] +} ``` -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' -``` - -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: - -```ts -interface SdkCache { - get(key: string): unknown | Promise; - set(key: string, value: unknown): void | Promise; -} +curl 'localhost:3030/health' ``` -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), -}; +Response: `SourceHealth[]` -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 +230,25 @@ 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 set on `startServer`, every `stream.url`, subtitle URL, and `pages[].url` in responses is rewritten through `/proxy`. Browsers can play the rewritten URLs directly. See [Stream Proxy](/docs/proxy/). + +## Downloads + +`/download/video/progress` and `/download/manga/chapter/progress` open SSE streams and emit `{ type: 'complete', token }` when the download is ready; `/download/*/file?token=…` then serves the bytes. See [Downloads](/docs/download/). + +## 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..3929f9c 100644 --- a/website/src/content/docs/docs/index.mdx +++ b/website/src/content/docs/docs/index.mdx @@ -3,218 +3,198 @@ 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 ## 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 -// 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 — all available streams from all sources, all languages +const streams = await sdk.stream(episodes[0]); +// Each Stream is one playable URL with .source, .server, .quality, .language +const preferred = streams.find((s) => s.language === 'sub') ?? streams[0]; +console.log(preferred.url); // playable URL +console.log(preferred.source); // which source provided this stream +console.log(preferred.server); // server name within the source +``` -`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; + source: string; + 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 + number: number; + title?: string; + languages?: ('sub' | 'dub' | 'raw')[]; } -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'; + source: string; + server: string; + quality: '1080p' | '720p' | '480p' | '360p' | 'auto'; + language: 'sub' | 'dub' | 'raw'; + isHls: boolean; + headers?: Record; + subtitles: { url: string; language: string; label: string; format: 'vtt' | 'srt' | 'ass' }[]; } ``` -## Sub / dub / raw - -`fetchContentUnits` returns one unified episode list: each `IContentUnit` advertises its translations via `availableLanguages: ContentLanguage[]`. The translation is picked at `resolveStream` time: +## Browse ```ts -type ContentLanguage = 'sub' | 'dub' | 'raw'; +const trending = await sdk.browse({ list: 'trending', kind: 'anime' }); +// → List = { items: Media[], nextCursor?: string } ``` -```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'); -``` - -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 `